mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-21 04:24:23 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user