From 41b3ed95aac04c84b60abf667729ca840951385c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:28:46 +0200 Subject: [PATCH] fix(ui): stabilize domain controls and unify resource navigation Preserve optimistic listbox selections during Livewire saves, refresh service domains after updates, and align application, service, and server navigation layouts. --- DESIGN.md | 28 + UI_REDESIGN.md | 687 ------------------ app/Livewire/Project/Service/Domains.php | 1 + resources/css/app.css | 65 +- .../applications/advanced.blade.php | 26 - .../views/components/forms/listbox.blade.php | 26 +- .../forms/searchable-listbox.blade.php | 19 +- resources/views/components/reicon.blade.php | 1 + .../components/services/advanced.blade.php | 51 -- .../project/application/domains.blade.php | 32 +- .../project/application/heading.blade.php | 44 +- .../application/partials/domain-row.blade.php | 31 +- .../project/service/heading.blade.php | 89 ++- .../service/partials/domain-table.blade.php | 28 +- .../views/livewire/server/navbar.blade.php | 126 ++-- tests/Feature/ApplicationDomainsTest.php | 44 +- .../Feature/ListboxTriggerTruncationTest.php | 25 + .../ResourceHeadingUnifiedNavbarTest.php | 51 +- .../SearchableListboxComponentTest.php | 12 + .../Feature/ServerNavbarStatusLayoutTest.php | 17 + tests/Feature/ServiceDomainsTest.php | 15 +- 21 files changed, 464 insertions(+), 954 deletions(-) delete mode 100644 UI_REDESIGN.md delete mode 100644 resources/views/components/applications/advanced.blade.php delete mode 100644 resources/views/components/services/advanced.blade.php diff --git a/DESIGN.md b/DESIGN.md index 3976dd12b..5546b28f0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -399,6 +399,34 @@ do not create an unnecessarily wide menu. Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The selected option is indicated inside the menu, not repeated on the trigger. +#### Livewire dropdown state synchronization + +Instant-save listboxes must not flash back to an older value while Livewire is +saving or morphing the DOM. Treat the Alpine selection as the current visual +state until its request finishes: + +- await the Livewire change handler and prevent overlapping selections while + it is running; +- when a client-managed listbox can be rerendered by an unrelated or stale + Livewire response, use the listbox's `preserveValue` option so the morph does + not replace its newer Alpine value; +- scope `preserveValue` to controls whose value is owned by that interaction; + do not use it when external server events must replace the displayed value; +- after saving through a related model, refresh the parent component's loaded + relationship before rendering the response. A database write alone does not + update an already-loaded Eloquent collection; +- use stable `wire:key` values for rows containing listboxes. Do not include the + selected value in the key, because recreating the Alpine component causes a + visible reset; +- remember that a portalled options panel is teleported outside its visual + wrapper. Guard selection in the Alpine handler itself rather than relying + only on `pointer-events` or a disabled wrapper. + +The failure mode to avoid is: selection B is shown optimistically, selection A +is chosen next, the response for B morphs the listbox back to B, then the later +response finally shows A. The control should remain on the newest accepted +selection throughout the save sequence. + #### Multi-select filter dropdowns Toolbar filters that can combine criteria use one multi-select listbox rather diff --git a/UI_REDESIGN.md b/UI_REDESIGN.md deleted file mode 100644 index bc690cd1c..000000000 --- a/UI_REDESIGN.md +++ /dev/null @@ -1,687 +0,0 @@ -# Coolify UI redesign - -This branch restyles Coolify without changing its Livewire + Blade + Alpine + -Tailwind v4 architecture. The visual system now covers the global shell, -project and environment pages, application navigation, settings surfaces, -tables, modals, toasts, terminals, and metrics. - -Use this file as the source of truth when updating another page. The older -Graphite-only notes are no longer accurate. - -Onboarding validation and live server validation checkpoints share -`` (idle / pending / running / success / error) inside a -compact divided list, not legacy green check SVGs or fixed-width status rows. - -> **Maintainer rules** -> -> - Keep the work frontend-focused unless existing data must be exposed to the -> view. -> - Preserve routes, Livewire bindings, permissions, confirmations, and working -> interactions while changing layout and presentation. -> - Do not write or run tests for this redesign branch. -> - Validate Blade with `docker exec coolify php artisan view:cache`, then clear -> it with `docker exec coolify php artisan view:clear`. -> - Build frontend assets in the Vitee container with -> `docker exec coolify-vite npm run build`. -> - Use existing components before adding another styling abstraction. -> - Use `` for dropdown controls. Never add a native -> `` on any redesigned route, including mobile -fallbacks. Use: - -```blade - -``` - -Boolean checkboxes should normally become descriptive two-option listboxes. -Use `.live` behavior only when the selection needs an immediate server -rerender. - -Keep checkboxes for compact permission matrices and multi-select lists. Those -controls must use the shared `x-forms.checkbox` anatomy: an 18px rounded custom -box, purple checked fill in light mode, yellow checked fill in dark mode, and a -high-contrast check mark. Never expose the browser or Tailwind Forms default -checkbox on a redesigned page. - -The popup panel uses a 10px radius around 6px options with a 4px inset. Keep -the option content left-aligned and size the panel to its content or trigger; -do not create an unnecessarily wide menu. - -Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The -selected option is indicated inside the menu, not repeated on the trigger. - -#### Multi-select filter dropdowns - -Toolbar filters that can combine criteria use one multi-select listbox rather -than separate dropdowns or a single selected value. Follow the deployment -history filter in -`resources/views/livewire/project/application/deployment/index.blade.php`: - -- set `aria-multiselectable="true"` on the listbox; -- group related options under compact uppercase labels; -- keep the dropdown open while options are toggled; -- use the shared 16px custom checkbox treatment: purple checked fill in light - mode, yellow checked fill in dark mode, and a high-contrast check mark; -- show the number of active selections in a small count pill on the static - `Filter` trigger; -- combine selections within one group with OR logic and combine different - groups with AND logic; -- constrain only the options area with `max-h-80 overflow-y-auto`; -- place a persistent `Reset filters` action in a separate footer below the - scrollable options, divided by a top border; -- disable the reset action when no filter is active, and close the dropdown - after resetting. - -Do not represent the empty state as a selectable `All` option. The footer reset -action is the single way to return the multi-select to its unfiltered state. - -### Standard table controls - -Dense tables use the shared `x-table.*` components so search, filters, sorting, -and backend loading states remain visually and behaviorally consistent: - -- `` owns the responsive search-left/actions-right layout; -- `` owns the search icon, optional loading indicator, clear - action, sizing, and input anatomy; -- `` owns the static Filter trigger, active-count pill, - multi-select panel, scrollable options area, and Reset filters footer; -- `` owns the static Sort trigger and single-select panel; -- `` overlays only the changing table data for backend search, - filter, sort, and pagination requests. - -Tables continue to own their filter options, sort choices, headers, rows, -queries, permissions, and empty states. Backend-filtered or paginated tables -must use `x-table.loading`; frontend-only Alpine tables reuse the same toolbar -and control anatomy but do not show an artificial loading state. - -### Buttons - -- neutral actions use the shared `.button`; -- primary actions use the theme-aware purple/yellow tint; -- destructive actions use the existing error treatment; -- use outline Reicons where a matching glyph exists; -- avoid raw browser-default buttons and old dark-mode purple fills. - -### Unsaved changes - -`resources/views/components/unsaved-bar.blade.php` is a compact floating -bottom-center pill. It contains: - -- “You have changes that haven't been saved yet.” -- a subtle Reset action; -- a theme-aware Save changes button matching the tab accent. - -On small viewports the pill is inset (`inset-x-3`) and stacks: full label on -the first line, Reset / Save on the second (right-aligned). From `sm` up it -returns to the centered single-row nowrap pill. - -Do not restore the old full-width footer. - -Deferred fields in one Livewire component use one floating unsaved bar and one -submit action. Do not add a separate “Save configuration” button to every -card. Selectors that are safe to persist independently should use the existing -instant-save pattern. - ---- - -## 7. Dense tables - -Collections with many rows should use the Cloudflare-inspired table pattern: - -- toolbar above the table; -- search on the left; -- filters, sort, view toggles, and Add on the right; -- 40px header row and roughly 48px data rows; -- subtle row hover; -- plain text or the shared status badge rather than large colored chips; -- compact action at the far right; -- no separate layer card for each item. - -Do not add a summary card above a table when it only repeats the row count, -current page, or refresh interval. Keep counts and pagination in the footer. -Background polling stays silent unless its state is actionable; do not add a -“Live updates” badge just to explain that a table refreshes. Filters only -render meaningful values; use the shared listbox instead of a number input or -browser-native control. - -The footer is always inside the table shell: - -- `Showing X–Y of Z` on the left; -- first, previous, current page, next, and last controls on the right. - -Hide the entire pagination footer when there is only one page (`totalPages > 1`). -A lone “1–2 of 2” bar with disabled controls adds noise and is unnecessary. - -Use `x-status-badge` for resource and execution state. It is a small neutral -pill with a semantic dot, not a full colored rectangle. - -Relevant classes: - -- `.data-table` -- `.data-table-header` -- `.data-table-row` -- `.table-badge` - -Create a page-specific grid class when columns differ. Add responsive rules -that hide secondary columns before allowing horizontal overflow. - ---- - -## 8. Modals, confirmations, and toasts - -### Modals - -`x-modal-input` and confirmation dialogs reuse the layer-card shell: - -- compact elevated header; -- nested base-color body; -- content-width desktop sizing; -- shared 32px controls; -- no redundant description below a self-explanatory title; -- custom listboxes instead of native browser selects; -- right-aligned footer actions below a divider; -- compact action buttons, never a submit button stretched by a column layout. - -Edit modals should use the same field layout and option set as their matching -create modal. - -### Command palette - -The global search command palette (`livewire:global-search`) is a compact -top-anchored overlay: - -- elevated shell with hairline ring and modal shadow (not a heavy floating card); -- recessed-neutral header strip with outline search glyph and 14px input; -- compact OS-aware mod+K (`⌘K` on macOS, `Ctrl+K` on Windows/Linux) / `/` / `ESC` kbd chips matching the sidebar search trigger; -- nested base-color results body with group labels in sentence case; -- dense result rows as inset 6px-radius pills (listbox anatomy), not full-bleed - bars with global focus rings; -- hover uses neutral fill; keyboard focus uses a soft accent wash plus a 2px - left rail — never the global `ring-2` / ring-offset treatment; -- create rows use a neutral plus tile that only picks up the accent when the - row is focused; -- type pills and quickcommand chips stay recessed; they tint with the accent - only on the focused row; -- neutral thin scrollbar inside the results body (not brand-colored); -- create-resource modals opened from the palette reuse the standard - `application-settings-section` layer-card shell. - -Preserve keyboard navigation (arrow keys, Enter via focused links, Escape to -clear then close), `/` and mod+K (⌘K / Ctrl+K by OS) open shortcuts, and the multi-step -server → destination → project → environment create flow. - -### Toasts - -`resources/views/components/toast.blade.php` provides the global -`window.toast(message, options)` API and Livewire event handling. - -Current toast behavior: - -- compact layered card, maximum width 26rem; -- Reicon status tile for success, info, warning, danger, or default; -- title plus optional description; -- dismiss and copy-details actions; -- up to four stacked notifications; -- four-second dismissal, paused while hovered; -- support for all six screen positions and sanitized custom HTML. - -Do not bring back the old oversized dark rectangle. - ---- - -## 9. Terminals, logs, and metrics - -### Terminals - -Application and server browser terminals use the same browser-oriented console -shell, theme picker, compact header controls, and outline `browser-terminal` -Reicon. Hide a container switcher when only one container exists. - -### Logs - -Runtime and deployment logs should feel like a clean terminal surface: - -- keep a single log stream inside one layer card instead of adding an - introductory card above it; -- one compact toolbar; -- a recessed monospace log viewport; -- search and line-count controls aligned with icon actions; -- clear live/follow state; -- fullscreen support without changing the control language; -- custom listbox-style menus instead of browser dropdowns. - -### Metrics - -Metrics pages use separate layer cards for range selection, CPU, and memory. -Charts follow the application metrics implementation: - -- 240px area chart; -- smooth 2px stroke and restrained gradient fill; -- dashed neutral grid; -- no ApexCharts toolbar; -- tooltip positioned at the hovered point; -- UTC on both axes and tooltip; -- 20% headroom above observed values; -- downsample long time ranges before rendering. - -Only add a metric if Sentinel exposes historical data for it. Current Sentinel -history endpoints store CPU and memory. Root filesystem usage is included in -the periodic push payload for threshold notifications, but it is not stored as -a historical Sentinel metric and has no history endpoint, so it cannot power a -disk-usage graph yet. - ---- - -## 10. Current reference surfaces - -Use these as implementation references: - -| Surface | Reference | -|---|---| -| Dashboard overview | `resources/views/livewire/dashboard.blade.php` | -| Top-level collection cards | `resources/views/livewire/project/index.blade.php`, `resources/views/source/all.blade.php` | -| Top-level family tabs | `resources/views/components/team/navbar.blade.php`, `resources/views/components/notification/navbar.blade.php` | -| General settings and form anatomy | `resources/views/livewire/project/application/general.blade.php` | -| Advanced settings | `resources/views/livewire/project/application/advanced.blade.php` | -| Fixed layer-2 resource navigation | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/livewire/server/navbar.blade.php` | -| Grouped settings sidebar | `resources/views/livewire/project/application/configuration.blade.php`, `resources/views/components/server/sidebar.blade.php` | -| Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` | -| Standard table toolbar controls | `resources/views/components/table/*` | -| Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` | -| Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` | -| Layer card | `resources/views/components/application/settings-section.blade.php` | -| Custom dropdown | `resources/views/components/forms/listbox.blade.php` | -| Empty state | `resources/views/components/empty.blade.php` | -| Status pill | `resources/views/components/status-badge.blade.php` | -| Floating save pill | `resources/views/components/unsaved-bar.blade.php` | -| Global toast | `resources/views/components/toast.blade.php` | -| Command palette / global search | `resources/views/livewire/global-search.blade.php` | -| Outline icons | `resources/views/components/reicon.blade.php` | -| Shared styling | `resources/css/app.css`, `resources/css/utilities.css` | -| HTTP error pages | `resources/views/components/error-page.blade.php`, `resources/views/errors/*` | - -Already restyled application configuration surfaces include General, Advanced, -Environment Variables, Persistent Storage, Servers, Scheduled Tasks, Webhooks, -Preview Deployments, Healthcheck, Rollback, Resource Limits, Resource -Operations, Metrics, Tags, and Danger Zone. - -HTTP error pages (400, 401, 402, 403, 404, 419, 429, 500, 503) use the shared -`` component on the public auth-style canvas: theme-aware status -code, compact title and muted description, neutral `.button` actions, and an -`auth-text-link`-style Contact support link. Keep copy sentence-case and avoid -oversized 200px status numbers. - ---- - -## 11. Restyling checklist - -1. Inventory every route and reusable partial in the family before editing. -2. Read the current Blade and Livewire class before changing presentation. -3. Preserve every existing action, authorization check, loading state, and - confirmation. -4. Add the correct dual navigation and scoped workspace/form class. -5. Convert meaningful groups to layer cards and use `gap-6`. -6. Make the responsive column count match the controls visible in every state. -7. Replace native selects and checkbox-style configuration with listboxes. -8. Use one save model per component: instant-save or one floating dirty bar. -9. Check nested radii using `outer = inner + inset`. -10. Keep modal descriptions purposeful and footer actions compact/right-aligned. -11. Use tables for dense collections and cards for forms or summaries. -12. Use `x-status-badge`, `x-empty`, and `x-reicon`. -13. Confirm light and dark accent behavior. -14. Check fixed-nav anchor offsets and responsive stacking. -15. Sweep every sibling route for legacy controls and shells. -16. Run `git diff --check`. -17. Compile Blade views in the `coolify` container. -18. Build assets in `coolify-vite`. -19. Hard-refresh and inspect the family routes in both themes. diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index eb2d68c82..b44481e84 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -130,6 +130,7 @@ class Domains extends Component $application->setNoindexDomains($domains); $application->save(); $this->service->parse(); + $this->refreshDomains(); $this->dispatch('configurationChanged')->to(ConfigurationChecker::class); $this->dispatch('success', 'Search engine indexing updated.'); } diff --git a/resources/css/app.css b/resources/css/app.css index ad3412bf2..16713718d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2109,11 +2109,15 @@ input[type="search"]::-webkit-search-results-decoration { } .domains-table-grid { - grid-template-columns: minmax(0, 1.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem; + grid-template-columns: minmax(0, 1.8fr) 8.5rem 10rem 11rem 6.5rem; +} + +.domains-table-grid-service { + grid-template-columns: minmax(0, 1.8fr) 8.5rem 10rem 6.5rem; } .domains-table-grid-compose { - grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem; + grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem 10rem 11rem 6.5rem; } .domains-mobile-label { @@ -2127,9 +2131,9 @@ input[type="search"]::-webkit-search-results-decoration { gap: 0.75rem; } - /* Hide "Last checked" (3rd of 4) */ - .domains-table-grid > :nth-child(3) { - display: none; + .domains-table-grid-service { + grid-template-columns: minmax(0, 1fr) 8.25rem 9rem 5.5rem; + gap: 0.75rem; } .domains-table-grid-compose { @@ -2137,20 +2141,21 @@ input[type="search"]::-webkit-search-results-decoration { gap: 0.75rem; } - /* Hide "Service" (2) and "Last checked" (4) of 5 */ - .domains-table-grid-compose > :nth-child(2), - .domains-table-grid-compose > :nth-child(4) { + /* Hide "Service" */ + .domains-table-grid-compose > :nth-child(2) { display: none; } } @media (max-width: 768px) { .data-table-header.domains-table-grid, + .data-table-header.domains-table-grid-service, .data-table-header.domains-table-grid-compose { display: none; } .data-table-row.domains-table-grid, + .data-table-row.domains-table-grid-service, .data-table-row.domains-table-grid-compose { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -2172,26 +2177,28 @@ input[type="search"]::-webkit-search-results-decoration { 'indexing indexing'; } - .data-table-row.domains-table-grid.domains-row-without-direction > :nth-child(5), - .data-table-row.domains-table-grid-compose.domains-row-without-direction > :nth-child(6) { + .data-table-row.domains-table-grid.domains-row-without-direction > :nth-child(4), + .data-table-row.domains-table-grid-compose.domains-row-without-direction > :nth-child(5) { display: none; } /* Domain cell */ .data-table-row.domains-table-grid > :nth-child(1), + .data-table-row.domains-table-grid-service > :nth-child(1), .data-table-row.domains-table-grid-compose > :nth-child(1) { grid-area: domain; min-width: 0; } .data-table-row.domains-table-grid > :nth-child(1) a, + .data-table-row.domains-table-grid-service > :nth-child(1) a, .data-table-row.domains-table-grid-compose > :nth-child(1) a { white-space: normal; overflow-wrap: anywhere; word-break: break-word; } - /* Non-compose: 1 Domain, 2 DNS, 3 Last checked, 4 Indexing, 5 Direction, 6 Actions */ + /* Non-compose: 1 Domain, 2 DNS, 3 Indexing, 4 Direction, 5 Actions */ .data-table-row.domains-table-grid > :nth-child(2) { grid-area: meta; display: flex !important; @@ -2201,23 +2208,37 @@ input[type="search"]::-webkit-search-results-decoration { } .data-table-row.domains-table-grid > :nth-child(3) { - display: none !important; - } - - .data-table-row.domains-table-grid > :nth-child(4) { grid-area: indexing; } - .data-table-row.domains-table-grid > :nth-child(5) { + .data-table-row.domains-table-grid > :nth-child(4) { grid-area: direction; } - .data-table-row.domains-table-grid > :nth-child(6) { + .data-table-row.domains-table-grid > :nth-child(5) { grid-area: actions; align-self: center; } - /* Compose: 1 Domain, 2 Service, 3 DNS, 4 Last checked, 5 Indexing, 6 Direction, 7 Actions */ + /* Compose service group: 1 Domain, 2 DNS, 3 Indexing, 4 Actions */ + .data-table-row.domains-table-grid-service > :nth-child(2) { + grid-area: meta; + display: flex !important; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + } + + .data-table-row.domains-table-grid-service > :nth-child(3) { + grid-area: indexing; + } + + .data-table-row.domains-table-grid-service > :nth-child(4) { + grid-area: actions; + align-self: center; + } + + /* Compose: 1 Domain, 2 Service, 3 DNS, 4 Indexing, 5 Direction, 6 Actions */ .data-table-row.domains-table-grid-compose > :nth-child(2) { display: none !important; } @@ -2231,18 +2252,14 @@ input[type="search"]::-webkit-search-results-decoration { } .data-table-row.domains-table-grid-compose > :nth-child(4) { - display: none !important; - } - - .data-table-row.domains-table-grid-compose > :nth-child(5) { grid-area: indexing; } - .data-table-row.domains-table-grid-compose > :nth-child(6) { + .data-table-row.domains-table-grid-compose > :nth-child(5) { grid-area: direction; } - .data-table-row.domains-table-grid-compose > :nth-child(7) { + .data-table-row.domains-table-grid-compose > :nth-child(6) { grid-area: actions; align-self: center; } diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php deleted file mode 100644 index f01ba0e73..000000000 --- a/resources/views/components/applications/advanced.blade.php +++ /dev/null @@ -1,26 +0,0 @@ -
- - - -
diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index 531d32660..b61396a0d 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -15,6 +15,7 @@ 'disabled' => false, 'tooltip' => true, 'portal' => false, + 'preserveValue' => false, ]) @php @@ -45,22 +46,33 @@
whereStartsWith('x-model') }} + }" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }" + {{ $attributes->whereStartsWith('x-model') }} {{ $attributes->whereStartsWith('x-effect') }} + @if ($preserveValue) wire:ignore @endif @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()"> - - -
diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index c8e778b07..b1a35cb21 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -202,20 +202,38 @@ wire:key="application-compose-domain-group-{{ $redirectWireKey }}" x-show="matchesDomainSearch(@js($serviceName.' '.$rows->pluck('url')->implode(' ')))" class="border-b border-neutral-200 last:border-b-0 dark:border-white/10"> -
+
{{ $serviceName }} +
+ + @if (auth()->user()?->can('update', $application) && ! $labelsAreWritable) + + @else + + {{ match ($serviceRedirects[$redirectWireKey] ?? 'both') { + 'www' => 'Redirect to www', + 'non-www' => 'Redirect to non-www', + default => 'Allow both', + } }} + + @endif +
-
+
Domain - DNS - Last checked + DNS Check Search engine indexing - Direction
@foreach ($rows as $row) @@ -232,7 +250,8 @@ 'application' => $application, 'labelsAreWritable' => $labelsAreWritable, 'isCompose' => false, - 'domainDirection' => $serviceRedirects[$redirectWireKey] ?? 'both', + 'showDirectionControl' => false, + 'domainGridClass' => 'domains-table-grid-service', ]) @endforeach
@@ -250,7 +269,6 @@
Domain DNS Check - Last checked Search engine indexing Direction diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index be825792f..8b7f43c0d 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -104,13 +104,13 @@ @else @endcan @@ -183,30 +183,28 @@ @if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)) Load a Compose file to deploy. @else - @if (!$application->destination->server->isSwarm()) - - @endif
- @if (str($application->status)->startsWith('exited')) - - - Deploy - - @else -
+ @endif + @if (!$application->destination->server->isSwarm()) + + + @endif
- @endif +
@endif
diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index 03e5ded07..d5baaee56 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -13,10 +13,7 @@ 'pending' => 'DNS pending', default => 'DNS unknown', }; - $checkedAt = ! empty($row['checked_at']) - ? \Illuminate\Support\Carbon::parse($row['checked_at'])->diffForHumans() - : null; - $gridClass = ($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid'; + $gridClass = $domainGridClass ?? (($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid'); $domainParts = $isSuggested ? null : parse_url($row['url']); $faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host']) ? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico' @@ -38,7 +35,7 @@ ->filter(fn ($item) => $redirectPairKey($item['url']) === $pairKey) ->keys() ->first(); - $showDirection = ! $isSuggested && $firstPairRowIndex === $index; + $showDirection = ($showDirectionControl ?? true) && ! $isSuggested && $firstPairRowIndex === $index; @endphp
@else @if ($faviconUrl) - + @endif {{ $row['url'] }} @@ -101,11 +105,6 @@ @endif
-
- {{ $checkedAt ?: '-' }} -
-
@unless ($isSuggested) Search engine indexing @@ -114,6 +113,7 @@ - @elseif (auth()->user()?->can('update', $application) && ! $labelsAreWritable) @php $rowDirection = $domainDirection ?? $redirect; @@ -140,6 +141,7 @@ @endif @if ($showDirection && auth()->user()?->can('update', $application) && ! $labelsAreWritable) {{ $directionLabel }} @endif
+ @endif
@can('update', $application) diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php index 6ab8ff28a..38e81f278 100644 --- a/resources/views/livewire/project/service/heading.blade.php +++ b/resources/views/livewire/project/service/heading.blade.php @@ -107,13 +107,13 @@ @else @endcan @@ -181,43 +181,70 @@ class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
@if ($service->isDeployable) -
- @if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded')) -
-
- @else - - - - Deploy - - @endif +
@else @@ -264,7 +291,7 @@ if (isDeploymentProgress) { $wire.$dispatch('error', - 'There is a deployment in progress.

You can force deploy in the Advanced section.'); + 'There is a deployment in progress.

You can force deploy from the Actions menu.'); return; } @@ -278,7 +305,7 @@ if (isDeploymentProgress) { $wire.$dispatch('error', - 'There is a deployment in progress.

You can force deploy in the Advanced section.'); + 'There is a deployment in progress.

You can force deploy from the Actions menu.'); return; } diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 1717ff4ce..c0de5b62d 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -11,8 +11,7 @@ @if ($showServiceColumn) Service @endif - DNS - Last checked + DNS Check Search engine indexing Direction @@ -39,9 +38,6 @@ 'pending' => 'DNS pending', default => 'DNS unknown', }; - $checkedAt = ! empty($row['checked_at']) - ? \Illuminate\Support\Carbon::parse($row['checked_at'])->diffForHumans() - : null; $serviceLabel = filled($row['service_name'] ?? null) ? \Illuminate\Support\Str::headline($row['service_name']) : '-'; @@ -84,12 +80,19 @@ @else @if ($faviconUrl) - + @endif
{{ $row['url'] }} @@ -123,10 +126,6 @@ @endif
-
- {{ $checkedAt ?: '-' }} -
-
@unless ($isSuggested) Search engine indexing @@ -136,6 +135,7 @@ @elseif (auth()->user()?->can('update', $service)) - + + + Restart Proxy @if ($traefikDashboardAvailable) - + + + Traefik Dashboard @endif @else @endif
@@ -276,55 +286,65 @@ @if ($server->proxySet()) @can('manageProxy', $server) -
- @if ($proxyCanBeStopped) -
- -
- @if ($traefikDashboardAvailable) - - Traefik Dashboard - - +
+ + +
@endcan @endif diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 309e69b9c..b2d5658ab 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -108,12 +108,39 @@ it('lists existing domains as individual rows', function () { ->assertSee('https://example.com') ->assertSee('https://www.example.com') ->assertSee('https://example.com/favicon.ico', false) + ->assertSee('class="relative size-4 shrink-0"', false) + ->assertSee('domain-favicon-fallback', false) + ->assertSee('class="invisible absolute inset-0 size-4 rounded-sm"', false) + ->assertSee('$el.previousElementSibling.classList.add(\'hidden\')', false) ->assertSee('x-on:error="$el.remove()"', false) + ->assertSee('class="min-w-0 flex-1 text-[13px]', false) ->html(); expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2); }); +it('shows one redirect direction control in each compose service header', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'api' => [ + 'domain' => 'https://api.example.com,https://www.api.example.com', + 'redirect' => 'www', + ], + ]), + ]); + + $html = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSuccessful() + ->assertSee('api') + ->html(); + + expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1) + ->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0) + ->and(substr_count($html, 'domain-direction-service-api'))->toBeGreaterThan(0); +}); + it('shows dns entries control next to Add', function () { Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSuccessful() @@ -1225,15 +1252,25 @@ it('uses the compact service domains layout for compose applications', function ->toContain('application-compose-domain-group-{{ $redirectWireKey }}') ->toContain('class="application-settings-section-body mt-1 scroll-mt-28') ->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]') - ->toContain('class="data-table-header domains-table-grid"') + ->toContain('class="data-table-header domains-table-grid-service"') ->toContain('Direction') ->toContain('Search engine indexing') + ->not->toContain('Last checked') ->not->toContain('id="edit-domain-direction"') - ->not->toContain('htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"') - ->not->toContain('aria-label="Redirect direction for {{ $serviceName }}"') + ->toContain('id="domain-direction-service-{{ $redirectWireKey }}"') + ->toContain('onChange="updateServiceRedirect"') + ->toContain("'showDirectionControl' => false") ->not->toContain('title="No domains for this service"'); }); +it('does not render a last checked column in the domains table', function () { + $view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php')); + $row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php')); + + expect($view)->not->toContain('Last checked') + ->and($row)->not->toContain('$checkedAt'); +}); + it('uses compact labeled domain cards on mobile', function () { $styles = file_get_contents(resource_path('css/app.css')); $row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php')); @@ -1407,6 +1444,7 @@ it('updates search engine indexing from the domains view', function () { ->assertSee('Direction') ->assertSee('toggleNoindexDomain', false) ->assertSee('updateRedirect', false) + ->assertSee('wire:ignore', false) ->assertDontSee('x-model="localIndexing"', false) ->assertDontSee('x-model="localDirection"', false) ->assertDontSee('@js(', false) diff --git a/tests/Feature/ListboxTriggerTruncationTest.php b/tests/Feature/ListboxTriggerTruncationTest.php index 1e9a46a19..666283edb 100644 --- a/tests/Feature/ListboxTriggerTruncationTest.php +++ b/tests/Feature/ListboxTriggerTruncationTest.php @@ -88,6 +88,31 @@ test('listbox forwards dynamic disabled state to its trigger', function () { ->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"'); }); +test('listbox waits for change handlers and prevents overlapping selections', function () { + $listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php')); + + expect($listbox) + ->toContain('saving: false') + ->toContain('async choose(option)') + ->toContain('await this.$wire.') + ->toContain('if (this.saving || option.disabled) return;') + ->toContain("'pointer-events-none opacity-70': saving"); +}); + +test('listbox does not send a second live entangle request when using a change handler', function () { + $listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php')); + + expect($listbox)->toContain('@elseif ($live && ! $onChange) @entangle($id).live'); +}); + +test('listbox can preserve its client value across Livewire morphs', function () { + $listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php')); + + expect($listbox) + ->toContain("'preserveValue' => false") + ->toContain('@if ($preserveValue) wire:ignore @endif'); +}); + test('notification event multiselect truncates long selected summaries', function () { $html = Blade::render(<<<'BLADE' \$sidebar"); }); -it('keeps application links next to advanced actions on the right', function () { +it('keeps advanced operations in a separated section at the bottom of actions menus', function () { $application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); + $service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); $links = file_get_contents(resource_path('views/components/applications/links.blade.php')); - $desktop = str($application)->after('resource-heading-actions flex')->toString(); - $advancedPosition = strpos($desktop, 'after('resource-heading-actions flex')->toString(); + $serviceDesktop = str($service)->after('resource-heading-actions flex')->toString(); - expect($advancedPosition)->not->toBeFalse() - ->and($linksPosition)->not->toBeFalse() - ->and($linksPosition)->toBeGreaterThan($advancedPosition) + expect($applicationDesktop) + ->not->toContain('toContain('application-desktop-actions') + ->toContain('role="separator"') + ->toContain('Force deploy without cache') + ->and($serviceDesktop) + ->not->toContain('toContain('service-desktop-actions') + ->toContain('role="separator"') + ->toContain('Pull Latest Images & Restart') + ->toContain('Force Restart') + ->toContain('Force Deploy') + ->toContain('Force Cleanup Containers') ->and($links)->toContain("'right-0! left-auto! min-w-60! max-w-96!' => !\$fullWidth") ->and($links)->toContain('listbox-option justify-start! gap-2.5!') ->and($links)->not->toContain('md:left-0 md:right-auto'); @@ -146,15 +156,15 @@ it('groups application lifecycle controls in an actions dropdown', function () { ->toContain('Deploy'); }); -it('shows deploy directly when it is the only available lifecycle action', function () { +it('keeps deploy in the actions menu alongside advanced operations', function () { $heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); $desktop = str($heading)->after('resource-heading-actions flex')->toString(); expect($desktop) ->toContain("@if (str(\$application->status)->startsWith('exited'))") - ->toContain('id="application-desktop-deploy"') - ->toContain('@else') - ->toContain('id="application-desktop-actions"'); + ->toContain('id="application-desktop-actions"') + ->toContain('Deploy') + ->toContain('Force deploy without cache'); }); it('moves application backups from the top tabs into the settings sidebar', function () { @@ -229,7 +239,24 @@ it('uses neutral icons for non-destructive resource actions', function () { ->not->toContain('class="size-3.5 text-orange-500') ->not->toContain('class="size-3.5 text-warning"') ->not->toContain('class="size-4 text-warning"') - ->toContain('name="stop" class="size-3.5 text-error"'); + ->toContain('name="stop-circle"'); + } +}); + +it('uses a circular stop icon in application and service action menus', function () { + $icons = file_get_contents(resource_path('views/components/reicon.blade.php')); + + expect($icons) + ->toContain("'stop-circle' =>") + ->toContain('toContain('toContain('name="stop-circle" class="size-3.5 text-error"') + ->not->toContain('name="stop" class="size-3.5 text-error"'); } }); diff --git a/tests/Feature/SearchableListboxComponentTest.php b/tests/Feature/SearchableListboxComponentTest.php index 33b67bc72..a0f66d422 100644 --- a/tests/Feature/SearchableListboxComponentTest.php +++ b/tests/Feature/SearchableListboxComponentTest.php @@ -52,3 +52,15 @@ test('searchable listbox keeps the helper outside the label association', functi ->toContain('aria-label="More information"') ->not->toMatch('/]*for="tz-trigger"[^>]*>[\s\S]*aria-label="More information"[\s\S]*<\/label>/'); }); + +test('searchable listbox serializes change handlers', function () { + $listbox = file_get_contents(resource_path('views/components/forms/searchable-listbox.blade.php')); + + expect($listbox) + ->toContain('saving: false') + ->toContain('async choose(option)') + ->toContain('if (this.saving || option.disabled)') + ->toContain('await this.$wire.') + ->toContain("'pointer-events-none opacity-70': saving") + ->toContain('@elseif ($live && ! $onChange) @entangle($id).live'); +}); diff --git a/tests/Feature/ServerNavbarStatusLayoutTest.php b/tests/Feature/ServerNavbarStatusLayoutTest.php index de55e1290..11b5b0b80 100644 --- a/tests/Feature/ServerNavbarStatusLayoutTest.php +++ b/tests/Feature/ServerNavbarStatusLayoutTest.php @@ -41,3 +41,20 @@ it('uses the branded input focus state for the server filter', function () { ->toContain('not->toContain('after('id="server-desktop-actions"')->before('@endteleport')->toString(); + + expect($desktopActions) + ->toContain('Actions') + ->toContain('Traefik Dashboard') + ->toContain('name="external-link" class="size-3! opacity-70"') + ->toContain('class="flex size-4 shrink-0 items-center justify-center"') + ->toContain('Restart Proxy') + ->toContain('Stop Proxy') + ->toContain('Start Proxy') + ->toContain('Refresh Proxy Status') + ->toContain('listbox-panel') + ->not->toContain('toContain("id=\"service-domain-direction-{$this->apiApp->id}-0-trigger\"") ->toContain("id=\"service-domain-indexing-{$this->apiApp->id}-0-trigger\"") ->toContain('src="https://api.example.com/favicon.ico"') + ->toContain('class="relative size-4 shrink-0"') + ->toContain('domain-favicon-fallback') + ->toContain('class="invisible absolute inset-0 size-4 rounded-sm"') + ->toContain('$el.previousElementSibling.classList.add(\'hidden\')') ->toContain('x-on:error="$el.remove()"') + ->toContain('class="min-w-0 flex-1 text-[13px]') ->toContain('class="listbox-trigger"') ->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible') ->toContain('dark:bg-white/[0.04]') ->toContain('Domain') - ->toContain('DNS') - ->toContain('Last checked') + ->toContain('DNS Check') + ->not->toContain('Last checked') ->not->toContain("service-domain-group-{$this->webApp->id}") ->and(substr_count($html, '2 domains'))->toBe(1) ->and(strpos($html, '>API'))->toBeLessThan(strpos($html, 'Domain')) @@ -556,12 +561,16 @@ it('updates search engine indexing from the service domains view', function () { ->assertSee('Direction') ->assertSee('toggleNoindexDomain', false) ->assertSee('updateServiceRedirect', false) + ->assertSee('wire:ignore', false) ->assertDontSee('x-model="localIndexing"', false) ->assertDontSee('x-model="localDirection"', false) ->assertDontSee('@js(', false) ->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex') ->assertDispatched('configurationChanged') - ->assertDispatched('success'); + ->assertDispatched('success') + ->assertSet('service', fn (Service $service): bool => $service->applications + ->firstWhere('id', $this->apiApp->id) + ?->isDomainNoindexed('https://api.example.com') === true); expect($this->apiApp->refresh()->noindexDomains()->all()) ->toBe(['https://api.example.com']);