feat(ui): scrollable resource tabs and railpack default

Add a shared resource-heading-tabs scroller with overflow chevrons,
wire it into resource/server navbars, keep links menus outside overflow,
and default new git apps to Railpack instead of Nixpacks.
This commit is contained in:
Andras Bacsai
2026-08-03 23:55:34 +02:00
parent 9721fcdf11
commit 1bb54c0ca1
21 changed files with 452 additions and 101 deletions
@@ -64,7 +64,7 @@ class GithubPrivateRepository extends Component
protected int $page = 1;
public $build_pack = 'nixpacks';
public $build_pack = 'railpack';
public bool $show_is_static = true;
@@ -47,7 +47,7 @@ class GithubPrivateRepositoryDeployKey extends Component
public string $branch;
public $build_pack = 'nixpacks';
public $build_pack = 'railpack';
public bool $show_is_static = true;
@@ -57,7 +57,7 @@ class GitlabPrivateRepository extends Component
protected int $page = 1;
public $build_pack = 'nixpacks';
public $build_pack = 'railpack';
public bool $show_is_static = true;
@@ -56,7 +56,7 @@ class PublicGitRepository extends Component
public string $git_repository;
public $build_pack = 'nixpacks';
public $build_pack = 'railpack';
public bool $show_is_static = true;
+104
View File
@@ -1152,6 +1152,110 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
height: 0;
}
/*
* Kumo-style overflow controls for resource heading tabs.
* Circular chevrons sit over gradient fades when more tabs are off-screen.
*/
.resource-heading-tabs-scroller {
--resource-heading-tabs-fade: #f5f5f5; /* neutral-100, matches pill track */
}
.dark .resource-heading-tabs-scroller {
/* Matches dark:bg-white/[0.035] pill track + panel so fade blends */
--resource-heading-tabs-fade: color-mix(in srgb, var(--color-panel, #0a0a0a) 96.5%, white);
}
.resource-heading-tabs-control {
position: absolute;
inset-block: 0;
z-index: 3;
display: flex;
align-items: center;
width: 2.5rem;
margin: 0;
padding: 0;
border: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.resource-heading-tabs-control.is-visible {
pointer-events: auto;
opacity: 1;
}
.resource-heading-tabs-control.is-start {
left: 0;
justify-content: flex-start;
background: linear-gradient(
to right,
var(--resource-heading-tabs-fade) 35%,
color-mix(in srgb, var(--resource-heading-tabs-fade) 85%, transparent) 70%,
transparent
);
}
.resource-heading-tabs-control.is-end {
right: 0;
justify-content: flex-end;
background: linear-gradient(
to left,
var(--resource-heading-tabs-fade) 35%,
color-mix(in srgb, var(--resource-heading-tabs-fade) 85%, transparent) 70%,
transparent
);
}
.resource-heading-tabs-control-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
margin-inline: 0.25rem;
border-radius: 9999px;
color: #737373;
background: #ffffff;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(0, 0, 0, 0.08);
transition:
background-color 0.15s ease,
color 0.15s ease,
box-shadow 0.15s ease;
}
.dark .resource-heading-tabs-control-icon {
color: var(--color-fg-dim, #a3a3a3);
background: var(--color-raised, #171717);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.1);
}
.resource-heading-tabs-control:hover .resource-heading-tabs-control-icon,
.resource-heading-tabs-control:focus-visible .resource-heading-tabs-control-icon {
color: #000000;
background: #ffffff;
}
.dark .resource-heading-tabs-control:hover .resource-heading-tabs-control-icon,
.dark .resource-heading-tabs-control:focus-visible .resource-heading-tabs-control-icon {
color: var(--color-fg, #fafafa);
background: var(--color-raised, #171717);
}
.resource-heading-tabs-control:focus {
outline: none;
}
.resource-heading-tabs-control:focus-visible .resource-heading-tabs-control-icon {
box-shadow:
0 0 0 2px color-mix(in srgb, var(--color-coollabs, #6b16ed) 45%, transparent),
0 0 0 1px rgba(0, 0, 0, 0.08);
}
.resource-heading-menus,
.resource-heading-actions {
overflow: visible;
@@ -22,7 +22,7 @@
</svg>
</button>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
class="absolute left-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised">
class="absolute right-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised md:left-0 md:right-auto">
@if ($hasLinks)
@if (data_get($application, 'gitBrancLocation'))
<a target="_blank" class="{{ $linkItemClasses }}" href="{{ $application->gitBranchLocation }}">
@@ -24,7 +24,7 @@
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="resource-heading-tabs flex min-w-0 items-center gap-0.5 overflow-x-auto">
<x-resource-heading-tabs class="min-w-0">
@foreach ($items as $item)
<a @class([
'app-tab shrink-0',
@@ -35,7 +35,7 @@
{{ $item['label'] }}
</a>
@endforeach
</div>
</x-resource-heading-tabs>
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
@isset($actions)
@@ -0,0 +1,167 @@
{{--
Scrollable resource tab strip with Kumo-style overflow arrows.
Left/right circular chevrons appear only when more tabs exist off-screen.
On load / Livewire navigate, the active tab is scrolled into view so
right-side items are not hidden after navigation on mobile.
--}}
@props([
'listClass' => '',
])
<div
{{ $attributes->class('resource-heading-tabs-scroller relative isolate min-w-0') }}
x-data="{
canStart: false,
canEnd: false,
ro: null,
mo: null,
onNavigated: null,
update() {
const el = this.$refs.list
if (!el) {
return
}
const max = Math.max(0, el.scrollWidth - el.clientWidth)
const left = Math.min(Math.max(0, el.scrollLeft), max)
this.canStart = left > 1
this.canEnd = (max - left) > 1
},
scrollByDir(dir) {
const el = this.$refs.list
if (!el) {
return
}
const amount = Math.max(80, Math.floor(el.clientWidth * 0.7))
el.scrollBy({ left: dir === 'start' ? -amount : amount, behavior: 'smooth' })
},
findActiveTab() {
const el = this.$refs.list
if (!el) {
return null
}
return Array.from(el.children).find((child) => {
if (!(child instanceof HTMLElement)) {
return false
}
if (child.getAttribute('aria-current') === 'page') {
return true
}
if (child.hasAttribute('data-active') || child.dataset.active === 'true') {
return true
}
// Active resource tabs use coollabs/warning highlight classes.
const cls = typeof child.className === 'string' ? child.className : ''
return cls.includes('ring-coollabs')
|| cls.includes('bg-coollabs')
|| cls.includes('ring-warning')
|| cls.includes('bg-warning')
}) ?? null
},
scrollActiveIntoView() {
const list = this.$refs.list
const active = this.findActiveTab()
if (!list || !active) {
this.update()
return
}
// Center the active tab so right-edge items are fully visible after navigate.
const listWidth = list.clientWidth
const maxScroll = Math.max(0, list.scrollWidth - listWidth)
const activeCenter = active.offsetLeft + (active.offsetWidth / 2)
const target = Math.min(maxScroll, Math.max(0, activeCenter - (listWidth / 2)))
list.scrollTo({ left: target, behavior: 'auto' })
this.update()
},
scheduleScrollActiveIntoView() {
// Layout may not be final on first paint (fonts, Livewire morph, flex).
this.$nextTick(() => {
this.scrollActiveIntoView()
requestAnimationFrame(() => {
this.scrollActiveIntoView()
requestAnimationFrame(() => this.scrollActiveIntoView())
})
})
},
init() {
this.onNavigated = () => this.scheduleScrollActiveIntoView()
document.addEventListener('livewire:navigated', this.onNavigated)
this.$nextTick(() => {
this.scheduleScrollActiveIntoView()
const el = this.$refs.list
if (!el || typeof ResizeObserver === 'undefined') {
return
}
this.ro = new ResizeObserver(() => {
this.update()
})
this.ro.observe(el)
if (typeof MutationObserver !== 'undefined') {
this.mo = new MutationObserver(() => {
this.update()
})
this.mo.observe(el, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'aria-current', 'data-active'] })
}
})
},
destroy() {
this.ro?.disconnect()
this.mo?.disconnect()
if (this.onNavigated) {
document.removeEventListener('livewire:navigated', this.onNavigated)
}
},
}"
x-init="init()"
@resize.window.debounce.50ms="update()"
>
<div
x-ref="list"
@scroll.passive="update()"
@class([
'resource-heading-tabs flex w-full min-w-0 items-center gap-0.5 overflow-x-auto',
$listClass,
])
:data-overflow-start="canStart ? '' : null"
:data-overflow-end="canEnd ? '' : null"
>
{{ $slot }}
</div>
<button
type="button"
class="resource-heading-tabs-control is-start"
aria-label="Scroll tabs left"
:aria-hidden="(!canStart).toString()"
:tabindex="canStart ? 0 : -1"
:class="canStart ? 'is-visible' : ''"
@click="scrollByDir('start')"
>
<span class="resource-heading-tabs-control-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" class="size-3.5">
<path d="M9.25 4.25L5.75 8L9.25 11.75" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</button>
<button
type="button"
class="resource-heading-tabs-control is-end"
aria-label="Scroll tabs right"
:aria-hidden="(!canEnd).toString()"
:tabindex="canEnd ? 0 : -1"
:class="canEnd ? 'is-visible' : ''"
@click="scrollByDir('end')"
>
<span class="resource-heading-tabs-control-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" class="size-3.5">
<path d="M6.75 4.25L10.25 8L6.75 11.75" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</button>
</div>
@@ -14,7 +14,7 @@
</svg>
</button>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
class="absolute left-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised">
class="absolute right-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised md:left-0 md:right-auto">
@foreach ($links as $link)
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ $link }}">
<x-external-link class="size-3.5 shrink-0" />
@@ -81,8 +81,8 @@
<div class="application-build-pack-options mb-5 border-b border-neutral-200 pb-5 dark:border-white/[0.07]">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="buildPack" label="Build strategy" live :options="[
['value' => 'railpack', 'label' => 'Railpack'],
['value' => 'nixpacks', 'label' => 'Nixpacks'],
['value' => 'railpack', 'label' => 'Railpack (beta)'],
['value' => 'static', 'label' => 'Static'],
['value' => 'dockerfile', 'label' => 'Dockerfile'],
['value' => 'dockercompose', 'label' => 'Compose'],
@@ -272,21 +272,27 @@
</div>
@endif
<div
class="flex min-w-0 items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
@foreach ($applicationMenuItems as $menuItem)
@php
$isMobileApplicationItemActive = $menuItem['active']
|| ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem);
@endphp
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $isMobileApplicationItemActive,
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
{{-- Tabs may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
<x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($applicationMenuItems as $menuItem)
@php
$isMobileApplicationItemActive = $menuItem['active']
|| ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem);
@endphp
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $isMobileApplicationItemActive,
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
</x-resource-heading-tabs>
<div class="resource-heading-menus shrink-0">
<x-applications.links :application="$application" />
</div>
</div>
<div class="hidden" aria-hidden="true">
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
@@ -318,7 +324,7 @@
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="application-primary-tabs flex min-w-0 items-center gap-0.5">
{{-- Tabs alone may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
<div class="resource-heading-tabs flex min-w-0 items-center gap-0.5 overflow-x-auto">
<x-resource-heading-tabs class="min-w-0">
@foreach ($applicationMenuItems as $menuItem)
@php
$isApplicationMenuItemActive = $menuItem['active']
@@ -338,7 +344,7 @@
@endif
</a>
@endforeach
</div>
</x-resource-heading-tabs>
<div class="resource-heading-menus shrink-0">
<x-applications.links :application="$application" />
</div>
@@ -150,17 +150,19 @@
@endif
<div
class="flex min-w-0 items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
@foreach ($databasePageItems as $menuItem)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($databasePageItems as $menuItem)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
</x-resource-heading-tabs>
</div>
</div>
@@ -168,7 +170,7 @@
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="resource-heading-tabs flex min-w-0 items-center gap-0.5 overflow-x-auto">
<x-resource-heading-tabs class="min-w-0">
@foreach ($databasePageItems as $menuItem)
<a wire:key="database-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
@@ -180,7 +182,7 @@
{{ $menuItem['label'] }}
</a>
@endforeach
</div>
</x-resource-heading-tabs>
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
@if ($database->destination->server->isFunctional())
@@ -56,8 +56,8 @@
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input id="branch" required label="Branch" />
<x-forms.listbox id="build_pack" label="Build pack" required live :options="[
['value' => 'railpack', 'label' => 'Railpack'],
['value' => 'nixpacks', 'label' => 'Nixpacks'],
['value' => 'railpack', 'label' => 'Railpack (Beta)'],
['value' => 'static', 'label' => 'Static'],
['value' => 'dockerfile', 'label' => 'Dockerfile'],
['value' => 'dockercompose', 'label' => 'Docker Compose'],
@@ -109,8 +109,8 @@
'label' => data_get($branch, 'name'),
])->values()->all()" />
<x-forms.listbox id="build_pack" label="Build pack" required live :options="[
['value' => 'railpack', 'label' => 'Railpack'],
['value' => 'nixpacks', 'label' => 'Nixpacks'],
['value' => 'railpack', 'label' => 'Railpack (Beta)'],
['value' => 'static', 'label' => 'Static'],
['value' => 'dockerfile', 'label' => 'Dockerfile'],
['value' => 'dockercompose', 'label' => 'Docker Compose'],
@@ -108,8 +108,8 @@
'label' => data_get($branch, 'name'),
])->values()->all()" />
<x-forms.listbox id="build_pack" label="Build pack" required live :options="[
['value' => 'railpack', 'label' => 'Railpack'],
['value' => 'nixpacks', 'label' => 'Nixpacks'],
['value' => 'railpack', 'label' => 'Railpack (Beta)'],
['value' => 'static', 'label' => 'Static'],
['value' => 'dockerfile', 'label' => 'Dockerfile'],
['value' => 'dockercompose', 'label' => 'Docker Compose'],
@@ -61,8 +61,8 @@
:disabled="$git_source !== 'other'"
helper="You can choose another branch after the application is created." />
<x-forms.listbox id="build_pack" label="Build pack" required live :options="[
['value' => 'railpack', 'label' => 'Railpack'],
['value' => 'nixpacks', 'label' => 'Nixpacks'],
['value' => 'railpack', 'label' => 'Railpack (Beta)'],
['value' => 'static', 'label' => 'Static'],
['value' => 'dockerfile', 'label' => 'Dockerfile'],
['value' => 'dockercompose', 'label' => 'Docker Compose'],
@@ -174,17 +174,23 @@
@endif
<div
class="flex min-w-0 items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
@foreach ($servicePageItems as $menuItem)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
{{-- Tabs may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
<x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($servicePageItems as $menuItem)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
</x-resource-heading-tabs>
<div class="resource-heading-menus shrink-0">
<x-services.links :service="$service" />
</div>
</div>
</div>
@@ -193,7 +199,7 @@
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="flex min-w-0 items-center gap-0.5">
<div class="resource-heading-tabs flex min-w-0 items-center gap-0.5 overflow-x-auto">
<x-resource-heading-tabs class="min-w-0">
@foreach ($servicePageItems as $menuItem)
<a wire:key="service-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
@@ -205,7 +211,7 @@
{{ $menuItem['label'] }}
</a>
@endforeach
</div>
</x-resource-heading-tabs>
<div class="resource-heading-menus shrink-0">
<x-services.links :service="$service" />
</div>
@@ -283,17 +283,19 @@
@endif
<div
class="flex min-w-0 items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
@foreach ($serverMenuItems as $menuItem)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($serverMenuItems as $menuItem)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
</x-resource-heading-tabs>
</div>
</div>
@@ -301,8 +303,7 @@
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div
class="application-primary-tabs resource-heading-tabs flex min-w-0 items-center gap-0.5 overflow-x-auto">
<x-resource-heading-tabs class="application-primary-tabs min-w-0">
@foreach ($serverMenuItems as $menuItem)
<a wire:key="server-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
@@ -318,7 +319,7 @@
@endif
</a>
@endforeach
</div>
</x-resource-heading-tabs>
@if ($server->proxySet())
@can('manageProxy', $server)
@@ -47,7 +47,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
test('existing application buildpack selector lists nixpacks before railpack', function () {
test('existing application buildpack selector lists railpack before nixpacks', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
@@ -62,12 +62,12 @@ test('existing application buildpack selector lists nixpacks before railpack', f
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSeeInOrder([
'<option value="railpack">Railpack</option>',
'<option value="nixpacks">Nixpacks</option>',
'<option value="railpack">Railpack (Beta)</option>',
], false);
});
test('existing application shows railpack beta label in build pack selector', function () {
test('existing application shows railpack without beta label in build pack selector', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
@@ -81,5 +81,7 @@ test('existing application shows railpack beta label in build pack selector', fu
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('Railpack (Beta)');
->assertSee('Railpack')
->assertDontSee('Railpack (Beta)')
->assertDontSee('Railpack (beta)');
});
@@ -20,30 +20,32 @@ beforeEach(function () {
});
describe('new application buildpack defaults', function () {
test('github app repository flow defaults to nixpacks', function () {
test('github app repository flow defaults to railpack', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->assertSet('build_pack', 'nixpacks');
->assertSet('build_pack', 'railpack');
});
test('deploy key repository flow defaults to nixpacks', function () {
test('deploy key repository flow defaults to railpack', function () {
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
->assertSet('build_pack', 'nixpacks');
->assertSet('build_pack', 'railpack');
});
test('public repository flow defaults to nixpacks and lists railpack second', function () {
test('public repository flow defaults to railpack and lists railpack first', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->assertSet('build_pack', 'nixpacks');
->assertSet('build_pack', 'railpack');
});
test('public repository flow keeps railpack available after branch lookup', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('branchFound', true)
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']);
->assertSeeInOrder(['Railpack', 'Nixpacks']);
});
test('deploy key repository flow shows railpack beta label in build pack selector without beta badge', function () {
test('deploy key repository flow shows railpack without beta label', function () {
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
->set('current_step', 'repository')
->assertSee('Railpack (Beta)');
->assertSee('Railpack')
->assertDontSee('Railpack (Beta)')
->assertDontSee('Railpack (beta)');
});
});
@@ -44,34 +44,95 @@ it('keeps Links dropdowns outside the scrollable tabs strip', 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'));
$css = file_get_contents(resource_path('css/app.css'));
$tabsComponent = file_get_contents(resource_path('views/components/resource-heading-tabs.blade.php'));
// Links must not sit inside the overflow-x-auto tabs scroller (causes scrollbar on open).
expect($application)
->toContain('resource-heading-tabs')
->toContain('<x-resource-heading-tabs')
->toContain('resource-heading-menus')
->toContain('<x-applications.links');
$tabsBlockEnd = strpos($application, 'resource-heading-menus');
$overflowInTabs = substr(
$application,
strpos($application, 'resource-heading-tabs'),
$tabsBlockEnd - strpos($application, 'resource-heading-tabs'),
);
// Mobile and desktop each have a tabs + menus pair; verify every pair.
preg_match_all('/<x-resource-heading-tabs/', $application, $applicationTabsMatches, PREG_OFFSET_CAPTURE);
preg_match_all('/resource-heading-menus/', $application, $applicationMenusMatches, PREG_OFFSET_CAPTURE);
expect($applicationTabsMatches[0])->toHaveCount(2);
expect($applicationMenusMatches[0])->toHaveCount(2);
expect($overflowInTabs)->not->toContain('<x-applications.links');
expect(strpos($application, '<x-applications.links'))->toBeGreaterThan($tabsBlockEnd);
foreach ($applicationTabsMatches[0] as $index => [$match, $tabsOffset]) {
$menusOffset = $applicationMenusMatches[0][$index][1];
$overflowInTabs = substr($application, $tabsOffset, $menusOffset - $tabsOffset);
$serviceTabsEnd = strpos($service, 'resource-heading-menus');
$serviceOverflow = substr(
$service,
strpos($service, 'resource-heading-tabs'),
$serviceTabsEnd - strpos($service, 'resource-heading-tabs'),
);
expect($overflowInTabs)->not->toContain('<x-applications.links');
expect(strpos($application, '<x-applications.links', $tabsOffset))
->toBeGreaterThan($menusOffset);
}
expect($serviceOverflow)->not->toContain('<x-services.links');
expect(strpos($service, '<x-services.links'))->toBeGreaterThan($serviceTabsEnd);
preg_match_all('/<x-resource-heading-tabs/', $service, $serviceTabsMatches, PREG_OFFSET_CAPTURE);
preg_match_all('/resource-heading-menus/', $service, $serviceMenusMatches, PREG_OFFSET_CAPTURE);
expect($serviceTabsMatches[0])->toHaveCount(2);
expect($serviceMenusMatches[0])->toHaveCount(2);
foreach ($serviceTabsMatches[0] as $index => [$match, $tabsOffset]) {
$menusOffset = $serviceMenusMatches[0][$index][1];
$overflowInTabs = substr($service, $tabsOffset, $menusOffset - $tabsOffset);
expect($overflowInTabs)->not->toContain('<x-services.links');
expect(strpos($service, '<x-services.links', $tabsOffset))
->toBeGreaterThan($menusOffset);
}
expect($css)
->toContain('.resource-heading-tabs::-webkit-scrollbar')
->toContain('scrollbar-width: none');
->toContain('scrollbar-width: none')
->toContain('.resource-heading-tabs-control')
->toContain('.resource-heading-tabs-control-icon');
// Kumo-style overflow chevrons live on the shared tabs component.
expect($tabsComponent)
->toContain('Scroll tabs left')
->toContain('Scroll tabs right')
->toContain('scrollByDir')
->toContain('canStart')
->toContain('canEnd')
->toContain('resource-heading-tabs-control is-start')
->toContain('resource-heading-tabs-control is-end')
->toContain('scrollActiveIntoView')
->toContain('findActiveTab')
->toContain('livewire:navigated');
});
it('shows application and service Links on mobile headings', 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'));
$mobileApplicationSection = str($application)->between('class="w-full md:hidden"', 'class="hidden w-full items-center md:flex')->toString();
$mobileServiceSection = str($service)->between('class="w-full md:hidden"', 'class="hidden w-full items-center md:flex')->toString();
expect($mobileApplicationSection)
->toContain('<x-applications.links')
->toContain('resource-heading-menus')
->toContain('<x-resource-heading-tabs');
expect($mobileServiceSection)
->toContain('<x-services.links')
->toContain('resource-heading-menus')
->toContain('<x-resource-heading-tabs');
// One mobile + one desktop instance.
expect(substr_count($application, '<x-applications.links'))->toBe(2);
expect(substr_count($service, '<x-services.links'))->toBe(2);
});
it('uses overflow scroll arrows on resource heading navbars', function () {
$files = [
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
resource_path('views/components/project/navbar.blade.php'),
];
foreach ($files as $path) {
expect(file_get_contents($path))->toContain('<x-resource-heading-tabs');
}
});