feat(application): show the full file path inline and warn on a repeated base directory

This commit is contained in:
peaklabs-dev
2026-10-03 19:43:34 +02:00
parent 650351e2da
commit 6f75c601c3
8 changed files with 129 additions and 151 deletions
@@ -0,0 +1,47 @@
@props([
'base',
'file' => null,
'fileLabel' => null,
'defaultFile' => null,
'disabled' => 'false',
])
<div wire:key="repository-paths-{{ $file }}" {{ $attributes->class('grid gap-4') }} x-data="{
base: $wire.entangle('{{ $base }}'),
file: {{ $file ? "\$wire.entangle('{$file}')" : 'null' }},
normalize(path) {
path = (path ?? '').trim().replace(/\/+$/, '');
return path === '' || path.startsWith('/') ? path : '/' + path;
},
get directory() {
return this.normalize(this.base);
},
get location() {
return this.normalize(this.file) || '{{ $defaultFile }}';
},
get repeatsBase() {
return this.directory !== '' && this.location.startsWith(this.directory + '/');
},
get suggestion() {
return this.location.slice(this.directory.length);
},
}">
<x-forms.input label="Base directory" placeholder="/"
helper="Repository directory used as the build root. Useful for monorepos." x-model="base"
x-bind:disabled="{{ $disabled }}" @blur="base = directory || '/'" />
@if ($file)
<x-forms.input :label="$fileLabel" :placeholder="$defaultFile" helper="Path relative to the base directory."
x-model="file" x-bind:disabled="{{ $disabled }}" @blur="file = location" />
<p class="col-span-full text-xs text-neutral-500 dark:text-fg-dim">
Resolved file:
<code class="font-mono text-coollabs dark:text-warning" x-text="directory + location"></code>
</p>
<x-callout class="col-span-full" x-cloak x-show="repeatsBase" title="Base directory is repeated">
{{ $fileLabel }} is relative to the base directory, so it should not start with
<code class="rounded bg-black/10 px-1 dark:bg-white/10" x-text="directory"></code> again.
<button type="button" class="font-medium underline" x-show="!({{ $disabled }})"
@click="file = suggestion">Use <span x-text="suggestion"></span></button>
</x-callout>
@endif
{{ $slot }}
</div>
@@ -143,36 +143,9 @@
<div class="flex flex-col gap-5">
@if ($buildPack === 'dockercompose')
<div class="flex flex-col gap-2">
<div x-data="{
baseDir: @entangle('baseDirectory'),
composeLocation: @entangle('dockerComposeLocation'),
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
path = path.replace(/\/+$/, '');
if (!path.startsWith('/')) {
path = '/' + path;
}
return path;
},
normalizeBaseDir() {
this.baseDir = this.normalizePath(this.baseDir);
},
normalizeComposeLocation() {
const path = this.normalizePath(this.composeLocation);
this.composeLocation = path === '/' ? '/docker-compose.yaml' : path;
}
}" class="grid gap-4 lg:grid-cols-2">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="/"
label="Base directory"
helper="Directory to use as root. Useful for monorepos." x-model="baseDir"
@blur="normalizeBaseDir()" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="/docker-compose.yaml"
label="Docker compose location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($baseDirectory . $dockerComposeLocation, '/') }}</span>"
x-model="composeLocation" @blur="normalizeComposeLocation()" />
</div>
<x-forms.repository-paths class="lg:grid-cols-2" base="baseDirectory"
file="dockerComposeLocation" fileLabel="Docker compose location"
defaultFile="/docker-compose.yaml" disabled="shouldDisable()" />
<div class="w-full sm:w-96">
<x-forms.checkbox instantSave id="isPreserveRepositoryEnabled"
label="Preserve repository during deployment"
@@ -215,37 +188,9 @@
@endif
</div>
@else
<div x-data="{
baseDir: @entangle('baseDirectory'),
dockerfileLocation: @entangle('dockerfileLocation'),
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
path = path.replace(/\/+$/, '');
if (!path.startsWith('/')) {
path = '/' + path;
}
return path;
},
normalizeBaseDir() {
this.baseDir = this.normalizePath(this.baseDir);
},
normalizeDockerfileLocation() {
const path = this.normalizePath(this.dockerfileLocation);
this.dockerfileLocation = path === '/' ? '/Dockerfile' : path;
}
}" class="grid gap-4 lg:grid-cols-2">
<x-forms.input placeholder="/"
label="Base directory" helper="Directory to use as root. Useful for monorepos."
x-bind:disabled="!canUpdate" x-model="baseDir" @blur="normalizeBaseDir()" />
@if ($buildPack === 'dockerfile' && !$application->dockerfile)
<x-forms.input placeholder="/Dockerfile"
label="Dockerfile location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->dockerfile_location, '/') }}</span>"
x-bind:disabled="!canUpdate" x-model="dockerfileLocation"
@blur="normalizeDockerfileLocation()" />
@endif
<x-forms.repository-paths class="lg:grid-cols-2" base="baseDirectory"
:file="$buildPack === 'dockerfile' && !$application->dockerfile ? 'dockerfileLocation' : null"
fileLabel="Dockerfile location" defaultFile="/Dockerfile" disabled="!canUpdate">
@if ($buildPack === 'dockerfile')
<x-forms.input id="dockerfileTargetBuild" label="Docker build stage target"
helper="Useful if you have multi-staged dockerfile."
@@ -260,8 +205,7 @@
label="Publish directory" x-bind:disabled="!canUpdate" />
@endif
@endif
</div>
</x-forms.repository-paths>
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
<div class="pb-4">
<x-forms.textarea
@@ -77,28 +77,9 @@
</div>
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: @js($base_directory),
composeLocation: @js($docker_compose_location),
normalize(path) {
if (!path || path.trim() === '') return '/';
const normalized = path.trim().replace(/\/+$/, '');
return normalized.startsWith('/') ? normalized : '/' + normalized;
},
}" class="grid gap-4 sm:grid-cols-2">
<x-forms.input placeholder="/" wire:model.defer="base_directory"
label="Base directory" helper="Repository directory used as the build root."
x-model="baseDir" @blur="baseDir = normalize(baseDir)" />
<x-forms.input placeholder="/docker-compose.yaml"
wire:model.defer="docker_compose_location" label="Compose file"
helper="Path relative to the base directory." x-model="composeLocation"
@blur="composeLocation = normalize(composeLocation).replace(/^\/$/, '/docker-compose.yaml')" />
<p class="sm:col-span-2 text-xs text-neutral-500 dark:text-fg-dim">
Resolved file:
<code class="font-mono text-coollabs dark:text-warning"
x-text='(baseDir === "/" ? "" : baseDir) + (composeLocation.startsWith("/") ? composeLocation : "/" + composeLocation)'></code>
</p>
</div>
<x-forms.repository-paths class="sm:grid-cols-2" base="base_directory"
file="docker_compose_location" fileLabel="Compose file"
defaultFile="/docker-compose.yaml" />
@else
<x-forms.input wire:model="base_directory" label="Base directory"
helper="Repository directory used as the build root." />
@@ -135,28 +135,9 @@
</div>
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: @js($base_directory),
composeLocation: @js($docker_compose_location),
normalize(path) {
if (!path || path.trim() === '') return '/';
const normalized = path.trim().replace(/\/+$/, '');
return normalized.startsWith('/') ? normalized : '/' + normalized;
},
}" class="grid gap-4 sm:grid-cols-2">
<x-forms.input placeholder="/" wire:model.defer="base_directory"
label="Base directory" helper="Repository directory used as the build root."
x-model="baseDir" @blur="baseDir = normalize(baseDir)" />
<x-forms.input placeholder="/docker-compose.yaml"
wire:model.defer="docker_compose_location" label="Compose file"
helper="Path relative to the base directory." x-model="composeLocation"
@blur="composeLocation = normalize(composeLocation).replace(/^\/$/, '/docker-compose.yaml')" />
<p class="sm:col-span-2 text-xs text-neutral-500 dark:text-fg-dim">
Resolved file:
<code class="font-mono text-coollabs dark:text-warning"
x-text='(baseDir === "/" ? "" : baseDir) + (composeLocation.startsWith("/") ? composeLocation : "/" + composeLocation)'></code>
</p>
</div>
<x-forms.repository-paths class="sm:grid-cols-2" base="base_directory"
file="docker_compose_location" fileLabel="Compose file"
defaultFile="/docker-compose.yaml" />
@else
<x-forms.input wire:model="base_directory" label="Base directory"
helper="Repository directory used as the build root." />
@@ -133,28 +133,9 @@
</div>
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: @js($base_directory),
composeLocation: @js($docker_compose_location),
normalize(path) {
if (!path || path.trim() === '') return '/';
const normalized = path.trim().replace(/\/+$/, '');
return normalized.startsWith('/') ? normalized : '/' + normalized;
},
}" class="grid gap-4 sm:grid-cols-2">
<x-forms.input placeholder="/" wire:model.defer="base_directory"
label="Base directory" helper="Repository directory used as the build root."
x-model="baseDir" @blur="baseDir = normalize(baseDir)" />
<x-forms.input placeholder="/docker-compose.yaml"
wire:model.defer="docker_compose_location" label="Compose file"
helper="Path relative to the base directory." x-model="composeLocation"
@blur="composeLocation = normalize(composeLocation).replace(/^\/$/, '/docker-compose.yaml')" />
<p class="sm:col-span-2 text-xs text-neutral-500 dark:text-fg-dim">
Resolved file:
<code class="font-mono text-coollabs dark:text-warning"
x-text='(baseDir === "/" ? "" : baseDir) + (composeLocation.startsWith("/") ? composeLocation : "/" + composeLocation)'></code>
</p>
</div>
<x-forms.repository-paths class="sm:grid-cols-2" base="base_directory"
file="docker_compose_location" fileLabel="Compose file"
defaultFile="/docker-compose.yaml" />
@else
<x-forms.input wire:model="base_directory" label="Base directory"
helper="Repository directory used as the build root." />
@@ -84,28 +84,9 @@
</div>
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: @js($base_directory),
composeLocation: @js($docker_compose_location),
normalize(path) {
if (!path || path.trim() === '') return '/';
const normalized = path.trim().replace(/\/+$/, '');
return normalized.startsWith('/') ? normalized : '/' + normalized;
},
}" class="grid gap-4 sm:grid-cols-2">
<x-forms.input placeholder="/" wire:model.defer="base_directory"
label="Base directory" helper="Repository directory used as the build root."
x-model="baseDir" @blur="baseDir = normalize(baseDir)" />
<x-forms.input placeholder="/docker-compose.yaml"
wire:model.defer="docker_compose_location" label="Compose file"
helper="Path relative to the base directory." x-model="composeLocation"
@blur="composeLocation = normalize(composeLocation).replace(/^\/$/, '/docker-compose.yaml')" />
<p class="sm:col-span-2 text-xs text-neutral-500 dark:text-fg-dim">
Resolved file:
<code class="font-mono text-coollabs dark:text-warning"
x-text='(baseDir === "/" ? "" : baseDir) + (composeLocation.startsWith("/") ? composeLocation : "/" + composeLocation)'></code>
</p>
</div>
<x-forms.repository-paths class="sm:grid-cols-2" base="base_directory"
file="docker_compose_location" fileLabel="Compose file"
defaultFile="/docker-compose.yaml" />
@else
<x-forms.input wire:model="base_directory" label="Base directory"
helper="Repository directory used as the build root." />
@@ -156,6 +156,38 @@ it('saves custom docker run options from the UI', function () {
->screenshot(filename: 'application-docker-run-options');
});
it('warns when the Dockerfile location repeats the base directory and applies the suggested fix', function () {
$this->application->update([
'build_pack' => 'dockerfile',
'base_directory' => '/apps/api',
'dockerfile_location' => '/apps/api/Dockerfile',
]);
loginAndSkipBoarding();
$page = visit(applicationConfigurationUrl(
$this->stack['project'],
$this->stack['environment'],
$this->application
));
$page->assertSee('Base directory is repeated')
->assertSee('/apps/api/apps/api/Dockerfile')
->screenshot(filename: 'application-dockerfile-location-repeats-base-directory')
->click('Use /Dockerfile')
->assertDontSee('Base directory is repeated')
->assertSee('/apps/api/Dockerfile');
submitLivewireForm($page);
$page->assertNoJavaScriptErrors()
->screenshot(filename: 'application-dockerfile-location-fixed');
$this->application->refresh();
expect($this->application->base_directory)->toBe('/apps/api')
->and($this->application->dockerfile_location)->toBe('/Dockerfile');
});
it('restores the default file location when the field is cleared', function (string $buildPack, string $column, string $default) {
$this->application->update(['build_pack' => $buildPack, $column => '/docker/custom']);
@@ -1,5 +1,6 @@
<?php
use App\Models\Application;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@@ -22,3 +23,33 @@ it('restores the default compose file when the field is cleared', function () {
->assertNoJavaScriptErrors()
->screenshot(filename: 'new-application-compose-file-cleared');
});
it('warns when the compose file repeats the base directory and creates the application with the suggested fix', function () {
$stack = seedBrowserResourceStack();
loginAndSkipBoarding();
$page = visit("/project/{$stack['project']->uuid}/environment/{$stack['environment']->uuid}/new?type=public&destination={$stack['destination']->uuid}");
$page->fill('repository_url', 'https://gitlab.com/example/monorepo.git')
->click('Check repository')
->assertSee('Build configuration');
selectListboxOption($page, 'build_pack', 'Docker Compose');
$page->fill("input[placeholder='/']", 'apps/api/')
->fill("input[placeholder='/docker-compose.yaml']", 'apps/api/compose.yaml')
->assertSee('Base directory is repeated')
->assertSee('/apps/api/apps/api/compose.yaml')
->screenshot(filename: 'new-application-compose-file-repeats-base-directory')
->click('button:has-text("Use /compose.yaml")')
->assertDontSee('Base directory is repeated')
->assertSee('/apps/api/compose.yaml')
->assertNoJavaScriptErrors()
->screenshot(filename: 'new-application-compose-file-fixed');
submitLivewireForm($page);
$application = Application::query()->where('git_repository', 'https://gitlab.com/example/monorepo.git')->sole();
expect($application->base_directory)->toBe('/apps/api')
->and($application->docker_compose_location)->toBe('/compose.yaml');
});