diff --git a/app/Livewire/Concerns/SynchronizesModelData.php b/app/Livewire/Concerns/SynchronizesModelData.php
new file mode 100644
index 000000000..f8218c715
--- /dev/null
+++ b/app/Livewire/Concerns/SynchronizesModelData.php
@@ -0,0 +1,35 @@
+ Array mapping property names to model keys (e.g., ['content' => 'fileStorage.content'])
+ */
+ abstract protected function getModelBindings(): array;
+
+ /**
+ * Synchronize component properties TO the model.
+ * Copies values from component properties to the model.
+ */
+ protected function syncToModel(): void
+ {
+ foreach ($this->getModelBindings() as $property => $modelKey) {
+ data_set($this, $modelKey, $this->{$property});
+ }
+ }
+
+ /**
+ * Synchronize component properties FROM the model.
+ * Copies values from the model to component properties.
+ */
+ protected function syncFromModel(): void
+ {
+ foreach ($this->getModelBindings() as $property => $modelKey) {
+ $this->{$property} = data_get($this, $modelKey);
+ }
+ }
+}
diff --git a/app/Livewire/MonacoEditor.php b/app/Livewire/MonacoEditor.php
index 54f0965a2..f660f9c13 100644
--- a/app/Livewire/MonacoEditor.php
+++ b/app/Livewire/MonacoEditor.php
@@ -25,7 +25,7 @@ public function __construct(
public bool $readonly,
public bool $allowTab,
public bool $spellcheck,
- public bool $autofocus = false,
+ public bool $autofocus,
public ?string $helper,
public bool $realtimeValidation,
public bool $allowToPeak,
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index b42f29fa5..a733d8cb3 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig;
+use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\Application;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -14,6 +15,7 @@
class General extends Component
{
use AuthorizesRequests;
+ use SynchronizesModelData;
public string $applicationId;
@@ -23,6 +25,8 @@ class General extends Component
public string $name;
+ public ?string $description = null;
+
public ?string $fqdn = null;
public string $git_repository;
@@ -31,14 +35,82 @@ class General extends Component
public ?string $git_commit_sha = null;
+ public ?string $install_command = null;
+
+ public ?string $build_command = null;
+
+ public ?string $start_command = null;
+
public string $build_pack;
+ public string $static_image;
+
+ public string $base_directory;
+
+ public ?string $publish_directory = null;
+
public ?string $ports_exposes = null;
+ public ?string $ports_mappings = null;
+
+ public ?string $custom_network_aliases = null;
+
+ public ?string $dockerfile = null;
+
+ public ?string $dockerfile_location = null;
+
+ public ?string $dockerfile_target_build = null;
+
+ public ?string $docker_registry_image_name = null;
+
+ public ?string $docker_registry_image_tag = null;
+
+ public ?string $docker_compose_location = null;
+
+ public ?string $docker_compose = null;
+
+ public ?string $docker_compose_raw = null;
+
+ public ?string $docker_compose_custom_start_command = null;
+
+ public ?string $docker_compose_custom_build_command = null;
+
+ public ?string $custom_labels = null;
+
+ public ?string $custom_docker_run_options = null;
+
+ public ?string $pre_deployment_command = null;
+
+ public ?string $pre_deployment_command_container = null;
+
+ public ?string $post_deployment_command = null;
+
+ public ?string $post_deployment_command_container = null;
+
+ public ?string $custom_nginx_configuration = null;
+
+ public bool $is_static = false;
+
+ public bool $is_spa = false;
+
+ public bool $is_build_server_enabled = false;
+
public bool $is_preserve_repository_enabled = false;
public bool $is_container_label_escape_enabled = true;
+ public bool $is_container_label_readonly_enabled = false;
+
+ public bool $is_http_basic_auth_enabled = false;
+
+ public ?string $http_basic_auth_username = null;
+
+ public ?string $http_basic_auth_password = null;
+
+ public ?string $watch_paths = null;
+
+ public string $redirect;
+
public $customLabels;
public bool $labelsChanged = false;
@@ -66,50 +138,50 @@ class General extends Component
protected function rules(): array
{
return [
- 'application.name' => ValidationPatterns::nameRules(),
- 'application.description' => ValidationPatterns::descriptionRules(),
- 'application.fqdn' => 'nullable',
- 'application.git_repository' => 'required',
- 'application.git_branch' => 'required',
- 'application.git_commit_sha' => 'nullable',
- 'application.install_command' => 'nullable',
- 'application.build_command' => 'nullable',
- 'application.start_command' => 'nullable',
- 'application.build_pack' => 'required',
- 'application.static_image' => 'required',
- 'application.base_directory' => 'required',
- 'application.publish_directory' => 'nullable',
- 'application.ports_exposes' => 'required',
- 'application.ports_mappings' => 'nullable',
- 'application.custom_network_aliases' => 'nullable',
- 'application.dockerfile' => 'nullable',
- 'application.docker_registry_image_name' => 'nullable',
- 'application.docker_registry_image_tag' => 'nullable',
- 'application.dockerfile_location' => 'nullable',
- 'application.docker_compose_location' => 'nullable',
- 'application.docker_compose' => 'nullable',
- 'application.docker_compose_raw' => 'nullable',
- 'application.dockerfile_target_build' => 'nullable',
- 'application.docker_compose_custom_start_command' => 'nullable',
- 'application.docker_compose_custom_build_command' => 'nullable',
- 'application.custom_labels' => 'nullable',
- 'application.custom_docker_run_options' => 'nullable',
- 'application.pre_deployment_command' => 'nullable',
- 'application.pre_deployment_command_container' => 'nullable',
- 'application.post_deployment_command' => 'nullable',
- 'application.post_deployment_command_container' => 'nullable',
- 'application.custom_nginx_configuration' => 'nullable',
- 'application.settings.is_static' => 'boolean|required',
- 'application.settings.is_spa' => 'boolean|required',
- 'application.settings.is_build_server_enabled' => 'boolean|required',
- 'application.settings.is_container_label_escape_enabled' => 'boolean|required',
- 'application.settings.is_container_label_readonly_enabled' => 'boolean|required',
- 'application.settings.is_preserve_repository_enabled' => 'boolean|required',
- 'application.is_http_basic_auth_enabled' => 'boolean|required',
- 'application.http_basic_auth_username' => 'string|nullable',
- 'application.http_basic_auth_password' => 'string|nullable',
- 'application.watch_paths' => 'nullable',
- 'application.redirect' => 'string|required',
+ 'name' => ValidationPatterns::nameRules(),
+ 'description' => ValidationPatterns::descriptionRules(),
+ 'fqdn' => 'nullable',
+ 'git_repository' => 'required',
+ 'git_branch' => 'required',
+ 'git_commit_sha' => 'nullable',
+ 'install_command' => 'nullable',
+ 'build_command' => 'nullable',
+ 'start_command' => 'nullable',
+ 'build_pack' => 'required',
+ 'static_image' => 'required',
+ 'base_directory' => 'required',
+ 'publish_directory' => 'nullable',
+ 'ports_exposes' => 'required',
+ 'ports_mappings' => 'nullable',
+ 'custom_network_aliases' => 'nullable',
+ 'dockerfile' => 'nullable',
+ 'docker_registry_image_name' => 'nullable',
+ 'docker_registry_image_tag' => 'nullable',
+ 'dockerfile_location' => 'nullable',
+ 'docker_compose_location' => 'nullable',
+ 'docker_compose' => 'nullable',
+ 'docker_compose_raw' => 'nullable',
+ 'dockerfile_target_build' => 'nullable',
+ 'docker_compose_custom_start_command' => 'nullable',
+ 'docker_compose_custom_build_command' => 'nullable',
+ 'custom_labels' => 'nullable',
+ 'custom_docker_run_options' => 'nullable',
+ 'pre_deployment_command' => 'nullable',
+ 'pre_deployment_command_container' => 'nullable',
+ 'post_deployment_command' => 'nullable',
+ 'post_deployment_command_container' => 'nullable',
+ 'custom_nginx_configuration' => 'nullable',
+ 'is_static' => 'boolean|required',
+ 'is_spa' => 'boolean|required',
+ 'is_build_server_enabled' => 'boolean|required',
+ 'is_container_label_escape_enabled' => 'boolean|required',
+ 'is_container_label_readonly_enabled' => 'boolean|required',
+ 'is_preserve_repository_enabled' => 'boolean|required',
+ 'is_http_basic_auth_enabled' => 'boolean|required',
+ 'http_basic_auth_username' => 'string|nullable',
+ 'http_basic_auth_password' => 'string|nullable',
+ 'watch_paths' => 'nullable',
+ 'redirect' => 'string|required',
];
}
@@ -118,31 +190,31 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
- 'application.name.required' => 'The Name field is required.',
- 'application.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
- 'application.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
- 'application.git_repository.required' => 'The Git Repository field is required.',
- 'application.git_branch.required' => 'The Git Branch field is required.',
- 'application.build_pack.required' => 'The Build Pack field is required.',
- 'application.static_image.required' => 'The Static Image field is required.',
- 'application.base_directory.required' => 'The Base Directory field is required.',
- 'application.ports_exposes.required' => 'The Exposed Ports field is required.',
- 'application.settings.is_static.required' => 'The Static setting is required.',
- 'application.settings.is_static.boolean' => 'The Static setting must be true or false.',
- 'application.settings.is_spa.required' => 'The SPA setting is required.',
- 'application.settings.is_spa.boolean' => 'The SPA setting must be true or false.',
- 'application.settings.is_build_server_enabled.required' => 'The Build Server setting is required.',
- 'application.settings.is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.',
- 'application.settings.is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.',
- 'application.settings.is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.',
- 'application.settings.is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.',
- 'application.settings.is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.',
- 'application.settings.is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.',
- 'application.settings.is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.',
- 'application.is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.',
- 'application.is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.',
- 'application.redirect.required' => 'The Redirect setting is required.',
- 'application.redirect.string' => 'The Redirect setting must be a string.',
+ 'name.required' => 'The Name field is required.',
+ 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
+ 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
+ 'git_repository.required' => 'The Git Repository field is required.',
+ 'git_branch.required' => 'The Git Branch field is required.',
+ 'build_pack.required' => 'The Build Pack field is required.',
+ 'static_image.required' => 'The Static Image field is required.',
+ 'base_directory.required' => 'The Base Directory field is required.',
+ 'ports_exposes.required' => 'The Exposed Ports field is required.',
+ 'is_static.required' => 'The Static setting is required.',
+ 'is_static.boolean' => 'The Static setting must be true or false.',
+ 'is_spa.required' => 'The SPA setting is required.',
+ 'is_spa.boolean' => 'The SPA setting must be true or false.',
+ 'is_build_server_enabled.required' => 'The Build Server setting is required.',
+ 'is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.',
+ 'is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.',
+ 'is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.',
+ 'is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.',
+ 'is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.',
+ 'is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.',
+ 'is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.',
+ 'is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.',
+ 'is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.',
+ 'redirect.required' => 'The Redirect setting is required.',
+ 'redirect.string' => 'The Redirect setting must be a string.',
]
);
}
@@ -193,11 +265,15 @@ public function mount()
$this->parsedServices = $this->application->parse();
if (is_null($this->parsedServices) || empty($this->parsedServices)) {
$this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
+ // Still sync data even if parse fails, so form fields are populated
+ $this->syncFromModel();
return;
}
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
+ // Still sync data even on error, so form fields are populated
+ $this->syncFromModel();
}
if ($this->application->build_pack === 'dockercompose') {
// Only update if user has permission
@@ -218,9 +294,6 @@ public function mount()
}
$this->parsedServiceDomains = $sanitizedDomains;
- $this->ports_exposes = $this->application->ports_exposes;
- $this->is_preserve_repository_enabled = $this->application->settings->is_preserve_repository_enabled;
- $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled;
$this->customLabels = $this->application->parseContainerLabels();
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) {
// Only update custom labels if user has permission
@@ -249,6 +322,60 @@ public function mount()
if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) {
$this->dispatch('configurationChanged');
}
+
+ // Sync data from model to properties at the END, after all business logic
+ // This ensures any modifications to $this->application during mount() are reflected in properties
+ $this->syncFromModel();
+ }
+
+ protected function getModelBindings(): array
+ {
+ return [
+ 'name' => 'application.name',
+ 'description' => 'application.description',
+ 'fqdn' => 'application.fqdn',
+ 'git_repository' => 'application.git_repository',
+ 'git_branch' => 'application.git_branch',
+ 'git_commit_sha' => 'application.git_commit_sha',
+ 'install_command' => 'application.install_command',
+ 'build_command' => 'application.build_command',
+ 'start_command' => 'application.start_command',
+ 'build_pack' => 'application.build_pack',
+ 'static_image' => 'application.static_image',
+ 'base_directory' => 'application.base_directory',
+ 'publish_directory' => 'application.publish_directory',
+ 'ports_exposes' => 'application.ports_exposes',
+ 'ports_mappings' => 'application.ports_mappings',
+ 'custom_network_aliases' => 'application.custom_network_aliases',
+ 'dockerfile' => 'application.dockerfile',
+ 'dockerfile_location' => 'application.dockerfile_location',
+ 'dockerfile_target_build' => 'application.dockerfile_target_build',
+ 'docker_registry_image_name' => 'application.docker_registry_image_name',
+ 'docker_registry_image_tag' => 'application.docker_registry_image_tag',
+ 'docker_compose_location' => 'application.docker_compose_location',
+ 'docker_compose' => 'application.docker_compose',
+ 'docker_compose_raw' => 'application.docker_compose_raw',
+ 'docker_compose_custom_start_command' => 'application.docker_compose_custom_start_command',
+ 'docker_compose_custom_build_command' => 'application.docker_compose_custom_build_command',
+ 'custom_labels' => 'application.custom_labels',
+ 'custom_docker_run_options' => 'application.custom_docker_run_options',
+ 'pre_deployment_command' => 'application.pre_deployment_command',
+ 'pre_deployment_command_container' => 'application.pre_deployment_command_container',
+ 'post_deployment_command' => 'application.post_deployment_command',
+ 'post_deployment_command_container' => 'application.post_deployment_command_container',
+ 'custom_nginx_configuration' => 'application.custom_nginx_configuration',
+ 'is_static' => 'application.settings.is_static',
+ 'is_spa' => 'application.settings.is_spa',
+ 'is_build_server_enabled' => 'application.settings.is_build_server_enabled',
+ 'is_preserve_repository_enabled' => 'application.settings.is_preserve_repository_enabled',
+ 'is_container_label_escape_enabled' => 'application.settings.is_container_label_escape_enabled',
+ 'is_container_label_readonly_enabled' => 'application.settings.is_container_label_readonly_enabled',
+ 'is_http_basic_auth_enabled' => 'application.is_http_basic_auth_enabled',
+ 'http_basic_auth_username' => 'application.http_basic_auth_username',
+ 'http_basic_auth_password' => 'application.http_basic_auth_password',
+ 'watch_paths' => 'application.watch_paths',
+ 'redirect' => 'application.redirect',
+ ];
}
public function instantSave()
@@ -256,6 +383,12 @@ public function instantSave()
try {
$this->authorize('update', $this->application);
+ $oldPortsExposes = $this->application->ports_exposes;
+ $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
+ $oldIsPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
+
+ $this->syncToModel();
+
if ($this->application->settings->isDirty('is_spa')) {
$this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static');
}
@@ -265,20 +398,21 @@ public function instantSave()
$this->application->settings->save();
$this->dispatch('success', 'Settings saved.');
$this->application->refresh();
+ $this->syncFromModel();
// If port_exposes changed, reset default labels
- if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
+ if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
$this->resetDefaultLabels(false);
}
- if ($this->is_preserve_repository_enabled !== $this->application->settings->is_preserve_repository_enabled) {
- if ($this->application->settings->is_preserve_repository_enabled === false) {
+ if ($oldIsPreserveRepositoryEnabled !== $this->is_preserve_repository_enabled) {
+ if ($this->is_preserve_repository_enabled === false) {
$this->application->fileStorages->each(function ($storage) {
- $storage->is_based_on_git = $this->application->settings->is_preserve_repository_enabled;
+ $storage->is_based_on_git = $this->is_preserve_repository_enabled;
$storage->save();
});
}
}
- if ($this->application->settings->is_container_label_readonly_enabled) {
+ if ($this->is_container_label_readonly_enabled) {
$this->resetDefaultLabels(false);
}
} catch (\Throwable $e) {
@@ -366,21 +500,21 @@ public function generateDomain(string $serviceName)
}
}
- public function updatedApplicationBaseDirectory()
+ public function updatedBaseDirectory()
{
- if ($this->application->build_pack === 'dockercompose') {
+ if ($this->build_pack === 'dockercompose') {
$this->loadComposeFile();
}
}
- public function updatedApplicationSettingsIsStatic($value)
+ public function updatedIsStatic($value)
{
if ($value) {
$this->generateNginxConfiguration();
}
}
- public function updatedApplicationBuildPack()
+ public function updatedBuildPack()
{
// Check if user has permission to update
try {
@@ -388,21 +522,28 @@ public function updatedApplicationBuildPack()
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User doesn't have permission, revert the change and return
$this->application->refresh();
+ $this->syncFromModel();
return;
}
- if ($this->application->build_pack !== 'nixpacks') {
+ // Sync property to model before checking/modifying
+ $this->syncToModel();
+
+ if ($this->build_pack !== 'nixpacks') {
+ $this->is_static = false;
$this->application->settings->is_static = false;
$this->application->settings->save();
} else {
- $this->application->ports_exposes = $this->ports_exposes = 3000;
+ $this->ports_exposes = 3000;
+ $this->application->ports_exposes = 3000;
$this->resetDefaultLabels(false);
}
- if ($this->application->build_pack === 'dockercompose') {
+ if ($this->build_pack === 'dockercompose') {
// Only update if user has permission
try {
$this->authorize('update', $this->application);
+ $this->fqdn = null;
$this->application->fqdn = null;
$this->application->settings->save();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
@@ -421,8 +562,9 @@ public function updatedApplicationBuildPack()
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
}
}
- if ($this->application->build_pack === 'static') {
- $this->application->ports_exposes = $this->ports_exposes = 80;
+ if ($this->build_pack === 'static') {
+ $this->ports_exposes = 80;
+ $this->application->ports_exposes = 80;
$this->resetDefaultLabels(false);
$this->generateNginxConfiguration();
}
@@ -438,8 +580,11 @@ public function getWildcardDomain()
$server = data_get($this->application, 'destination.server');
if ($server) {
$fqdn = generateUrl(server: $server, random: $this->application->uuid);
- $this->application->fqdn = $fqdn;
+ $this->fqdn = $fqdn;
+ $this->syncToModel();
$this->application->save();
+ $this->application->refresh();
+ $this->syncFromModel();
$this->resetDefaultLabels();
$this->dispatch('success', 'Wildcard domain generated.');
}
@@ -453,8 +598,11 @@ public function generateNginxConfiguration($type = 'static')
try {
$this->authorize('update', $this->application);
- $this->application->custom_nginx_configuration = defaultNginxConfiguration($type);
+ $this->custom_nginx_configuration = defaultNginxConfiguration($type);
+ $this->syncToModel();
$this->application->save();
+ $this->application->refresh();
+ $this->syncFromModel();
$this->dispatch('success', 'Nginx configuration generated.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -464,15 +612,16 @@ public function generateNginxConfiguration($type = 'static')
public function resetDefaultLabels($manualReset = false)
{
try {
- if (! $this->application->settings->is_container_label_readonly_enabled && ! $manualReset) {
+ if (! $this->is_container_label_readonly_enabled && ! $manualReset) {
return;
}
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
- $this->ports_exposes = $this->application->ports_exposes;
- $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled;
- $this->application->custom_labels = base64_encode($this->customLabels);
+ $this->custom_labels = base64_encode($this->customLabels);
+ $this->syncToModel();
$this->application->save();
- if ($this->application->build_pack === 'dockercompose') {
+ $this->application->refresh();
+ $this->syncFromModel();
+ if ($this->build_pack === 'dockercompose') {
$this->loadComposeFile(showToast: false);
}
$this->dispatch('configurationChanged');
@@ -483,8 +632,8 @@ public function resetDefaultLabels($manualReset = false)
public function checkFqdns($showToaster = true)
{
- if (data_get($this->application, 'fqdn')) {
- $domains = str($this->application->fqdn)->trim()->explode(',');
+ if ($this->fqdn) {
+ $domains = str($this->fqdn)->trim()->explode(',');
if ($this->application->additional_servers->count() === 0) {
foreach ($domains as $domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
@@ -507,7 +656,8 @@ public function checkFqdns($showToaster = true)
$this->forceSaveDomains = false;
}
- $this->application->fqdn = $domains->implode(',');
+ $this->fqdn = $domains->implode(',');
+ $this->application->fqdn = $this->fqdn;
$this->resetDefaultLabels(false);
}
@@ -547,21 +697,27 @@ public function submit($showToaster = true)
$this->validate();
- $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
- $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
- $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
+ $oldPortsExposes = $this->application->ports_exposes;
+ $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
+ $oldDockerComposeLocation = $this->initialDockerComposeLocation;
+
+ // Process FQDN with intermediate variable to avoid Collection/string confusion
+ $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
+ $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
+ $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
- $this->application->fqdn = $this->application->fqdn->unique()->implode(',');
- $warning = sslipDomainWarning($this->application->fqdn);
+ $this->fqdn = $domains->unique()->implode(',');
+ $warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
- // $this->resetDefaultLabels();
+
+ $this->syncToModel();
if ($this->application->isDirty('redirect')) {
$this->setRedirect();
@@ -581,38 +737,42 @@ public function submit($showToaster = true)
$this->application->save();
}
- if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) {
+ if ($this->build_pack === 'dockercompose' && $oldDockerComposeLocation !== $this->docker_compose_location) {
$compose_return = $this->loadComposeFile(showToast: false);
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
return;
}
}
- if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
+ if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
$this->resetDefaultLabels();
}
- if (data_get($this->application, 'build_pack') === 'dockerimage') {
+ if ($this->build_pack === 'dockerimage') {
$this->validate([
- 'application.docker_registry_image_name' => 'required',
+ 'docker_registry_image_name' => 'required',
]);
}
- if (data_get($this->application, 'custom_docker_run_options')) {
- $this->application->custom_docker_run_options = str($this->application->custom_docker_run_options)->trim();
+ if ($this->custom_docker_run_options) {
+ $this->custom_docker_run_options = str($this->custom_docker_run_options)->trim()->toString();
+ $this->application->custom_docker_run_options = $this->custom_docker_run_options;
}
- if (data_get($this->application, 'dockerfile')) {
- $port = get_port_from_dockerfile($this->application->dockerfile);
- if ($port && ! $this->application->ports_exposes) {
+ if ($this->dockerfile) {
+ $port = get_port_from_dockerfile($this->dockerfile);
+ if ($port && ! $this->ports_exposes) {
+ $this->ports_exposes = $port;
$this->application->ports_exposes = $port;
}
}
- if ($this->application->base_directory && $this->application->base_directory !== '/') {
- $this->application->base_directory = rtrim($this->application->base_directory, '/');
+ if ($this->base_directory && $this->base_directory !== '/') {
+ $this->base_directory = rtrim($this->base_directory, '/');
+ $this->application->base_directory = $this->base_directory;
}
- if ($this->application->publish_directory && $this->application->publish_directory !== '/') {
- $this->application->publish_directory = rtrim($this->application->publish_directory, '/');
+ if ($this->publish_directory && $this->publish_directory !== '/') {
+ $this->publish_directory = rtrim($this->publish_directory, '/');
+ $this->application->publish_directory = $this->publish_directory;
}
- if ($this->application->build_pack === 'dockercompose') {
+ if ($this->build_pack === 'dockercompose') {
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
@@ -643,12 +803,12 @@ public function submit($showToaster = true)
}
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
+ $this->application->refresh();
+ $this->syncFromModel();
$showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!');
} catch (\Throwable $e) {
- $originalFqdn = $this->application->getOriginal('fqdn');
- if ($originalFqdn !== $this->application->fqdn) {
- $this->application->fqdn = $originalFqdn;
- }
+ $this->application->refresh();
+ $this->syncFromModel();
return handleError($e, $this);
} finally {
diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php
index 1cb2ef2c5..e28c8142d 100644
--- a/app/Livewire/Project/Application/Previews.php
+++ b/app/Livewire/Project/Application/Previews.php
@@ -33,14 +33,34 @@ class Previews extends Component
public $pendingPreviewId = null;
+ public array $previewFqdns = [];
+
protected $rules = [
- 'application.previews.*.fqdn' => 'string|nullable',
+ 'previewFqdns.*' => 'string|nullable',
];
public function mount()
{
$this->pull_requests = collect();
$this->parameters = get_route_parameters();
+ $this->syncData(false);
+ }
+
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ foreach ($this->previewFqdns as $key => $fqdn) {
+ $preview = $this->application->previews->get($key);
+ if ($preview) {
+ $preview->fqdn = $fqdn;
+ }
+ }
+ } else {
+ $this->previewFqdns = [];
+ foreach ($this->application->previews as $key => $preview) {
+ $this->previewFqdns[$key] = $preview->fqdn;
+ }
+ }
}
public function load_prs()
@@ -73,35 +93,52 @@ public function save_preview($preview_id)
$this->authorize('update', $this->application);
$success = true;
$preview = $this->application->previews->find($preview_id);
- if (data_get_str($preview, 'fqdn')->isNotEmpty()) {
- $preview->fqdn = str($preview->fqdn)->replaceEnd(',', '')->trim();
- $preview->fqdn = str($preview->fqdn)->replaceStart(',', '')->trim();
- $preview->fqdn = str($preview->fqdn)->trim()->lower();
- if (! validateDNSEntry($preview->fqdn, $this->application->destination->server)) {
- $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.
$preview->fqdn->{$this->application->destination->server->ip}
Check this documentation for further help.");
- $success = false;
- }
- // Check for domain conflicts if not forcing save
- if (! $this->forceSaveDomains) {
- $result = checkDomainUsage(resource: $this->application, domain: $preview->fqdn);
- if ($result['hasConflicts']) {
- $this->domainConflicts = $result['conflicts'];
- $this->showDomainConflictModal = true;
- $this->pendingPreviewId = $preview_id;
-
- return;
- }
- } else {
- // Reset the force flag after using it
- $this->forceSaveDomains = false;
- }
- }
if (! $preview) {
throw new \Exception('Preview not found');
}
- $success && $preview->save();
- $success && $this->dispatch('success', 'Preview saved.
Do not forget to redeploy the preview to apply the changes.');
+
+ // Find the key for this preview in the collection
+ $previewKey = $this->application->previews->search(function ($item) use ($preview_id) {
+ return $item->id == $preview_id;
+ });
+
+ if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
+ $fqdn = $this->previewFqdns[$previewKey];
+
+ if (! empty($fqdn)) {
+ $fqdn = str($fqdn)->replaceEnd(',', '')->trim();
+ $fqdn = str($fqdn)->replaceStart(',', '')->trim();
+ $fqdn = str($fqdn)->trim()->lower();
+ $this->previewFqdns[$previewKey] = $fqdn;
+
+ if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
+ $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.
$fqdn->{$this->application->destination->server->ip}
Check this documentation for further help.");
+ $success = false;
+ }
+
+ // Check for domain conflicts if not forcing save
+ if (! $this->forceSaveDomains) {
+ $result = checkDomainUsage(resource: $this->application, domain: $fqdn);
+ if ($result['hasConflicts']) {
+ $this->domainConflicts = $result['conflicts'];
+ $this->showDomainConflictModal = true;
+ $this->pendingPreviewId = $preview_id;
+
+ return;
+ }
+ } else {
+ // Reset the force flag after using it
+ $this->forceSaveDomains = false;
+ }
+ }
+ }
+
+ if ($success) {
+ $this->syncData(true);
+ $preview->save();
+ $this->dispatch('success', 'Preview saved.
Do not forget to redeploy the preview to apply the changes.');
+ }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -121,6 +158,7 @@ public function generate_preview($preview_id)
if ($this->application->build_pack === 'dockercompose') {
$preview->generate_preview_fqdn_compose();
$this->application->refresh();
+ $this->syncData(false);
$this->dispatch('success', 'Domain generated.');
return;
@@ -128,6 +166,7 @@ public function generate_preview($preview_id)
$preview->generate_preview_fqdn();
$this->application->refresh();
+ $this->syncData(false);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.');
} catch (\Throwable $e) {
@@ -152,6 +191,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null)
}
$found->generate_preview_fqdn_compose();
$this->application->refresh();
+ $this->syncData(false);
} else {
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
@@ -164,6 +204,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null)
}
$found->generate_preview_fqdn();
$this->application->refresh();
+ $this->syncData(false);
$this->dispatch('update_links');
$this->dispatch('success', 'Preview added.');
}
diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php
index cfb364b6d..942dfeb37 100644
--- a/app/Livewire/Project/Application/PreviewsCompose.php
+++ b/app/Livewire/Project/Application/PreviewsCompose.php
@@ -18,6 +18,13 @@ class PreviewsCompose extends Component
public ApplicationPreview $preview;
+ public ?string $domain = null;
+
+ public function mount()
+ {
+ $this->domain = data_get($this->service, 'domain');
+ }
+
public function render()
{
return view('livewire.project.application.previews-compose');
@@ -28,10 +35,10 @@ public function save()
try {
$this->authorize('update', $this->preview->application);
- $domain = data_get($this->service, 'domain');
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
- $docker_compose_domains = json_decode($docker_compose_domains, true);
- $docker_compose_domains[$this->serviceName]['domain'] = $domain;
+ $docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
+ $docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? [];
+ $docker_compose_domains[$this->serviceName]['domain'] = $this->domain;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
$this->dispatch('update_links');
@@ -46,7 +53,7 @@ public function generate()
try {
$this->authorize('update', $this->preview->application);
- $domains = collect(json_decode($this->preview->application->docker_compose_domains)) ?? collect();
+ $domains = collect(json_decode($this->preview->application->docker_compose_domains, true) ?: []);
$domain = $domains->first(function ($_, $key) {
return $key === $this->serviceName;
});
@@ -68,24 +75,40 @@ public function generate()
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else {
// Use the existing domain from the main application
- $url = Url::fromString($domain_string);
+ // Handle multiple domains separated by commas
+ $domain_list = explode(',', $domain_string);
+ $preview_fqdns = [];
$template = $this->preview->application->preview_url_template;
- $host = $url->getHost();
- $schema = $url->getScheme();
- $portInt = $url->getPort();
- $port = $portInt !== null ? ':'.$portInt : '';
$random = new Cuid2;
- $preview_fqdn = str_replace('{{random}}', $random, $template);
- $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
- $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
- $preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn";
+
+ foreach ($domain_list as $single_domain) {
+ $single_domain = trim($single_domain);
+ if (empty($single_domain)) {
+ continue;
+ }
+
+ $url = Url::fromString($single_domain);
+ $host = $url->getHost();
+ $schema = $url->getScheme();
+ $portInt = $url->getPort();
+ $port = $portInt !== null ? ':'.$portInt : '';
+
+ $preview_fqdn = str_replace('{{random}}', $random, $template);
+ $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
+ $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
+ $preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn);
+ $preview_fqdns[] = "$schema://$preview_fqdn";
+ }
+
+ $preview_fqdn = implode(',', $preview_fqdns);
}
// Save the generated domain
+ $this->domain = $preview_fqdn;
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
- $docker_compose_domains = json_decode($docker_compose_domains, true);
- $docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn;
+ $docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
+ $docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? [];
+ $docker_compose_domains[$this->serviceName]['domain'] = $this->domain;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php
index 513ba9f16..7c64a6eef 100644
--- a/app/Livewire/Project/Database/Configuration.php
+++ b/app/Livewire/Project/Database/Configuration.php
@@ -9,6 +9,7 @@
class Configuration extends Component
{
use AuthorizesRequests;
+
public $currentRoute;
public $database;
diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php
index abf4c45a7..4bcf866d3 100644
--- a/app/Livewire/Project/Service/Database.php
+++ b/app/Livewire/Project/Service/Database.php
@@ -24,16 +24,30 @@ class Database extends Component
public $parameters;
+ public ?string $humanName = null;
+
+ public ?string $description = null;
+
+ public ?string $image = null;
+
+ public bool $excludeFromStatus = false;
+
+ public ?int $publicPort = null;
+
+ public bool $isPublic = false;
+
+ public bool $isLogDrainEnabled = false;
+
protected $listeners = ['refreshFileStorages'];
protected $rules = [
- 'database.human_name' => 'nullable',
- 'database.description' => 'nullable',
- 'database.image' => 'required',
- 'database.exclude_from_status' => 'required|boolean',
- 'database.public_port' => 'nullable|integer',
- 'database.is_public' => 'required|boolean',
- 'database.is_log_drain_enabled' => 'required|boolean',
+ 'humanName' => 'nullable',
+ 'description' => 'nullable',
+ 'image' => 'required',
+ 'excludeFromStatus' => 'required|boolean',
+ 'publicPort' => 'nullable|integer',
+ 'isPublic' => 'required|boolean',
+ 'isLogDrainEnabled' => 'required|boolean',
];
public function render()
@@ -50,11 +64,33 @@ public function mount()
$this->db_url_public = $this->database->getServiceDatabaseUrl();
}
$this->refreshFileStorages();
+ $this->syncData(false);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->database->human_name = $this->humanName;
+ $this->database->description = $this->description;
+ $this->database->image = $this->image;
+ $this->database->exclude_from_status = $this->excludeFromStatus;
+ $this->database->public_port = $this->publicPort;
+ $this->database->is_public = $this->isPublic;
+ $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
+ } else {
+ $this->humanName = $this->database->human_name;
+ $this->description = $this->database->description;
+ $this->image = $this->database->image;
+ $this->excludeFromStatus = $this->database->exclude_from_status ?? false;
+ $this->publicPort = $this->database->public_port;
+ $this->isPublic = $this->database->is_public ?? false;
+ $this->isLogDrainEnabled = $this->database->is_log_drain_enabled ?? false;
+ }
+ }
+
public function delete($password)
{
try {
@@ -92,7 +128,7 @@ public function instantSaveLogDrain()
try {
$this->authorize('update', $this->database);
if (! $this->database->service->destination->server->isLogDrainEnabled()) {
- $this->database->is_log_drain_enabled = false;
+ $this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
@@ -145,15 +181,17 @@ public function instantSave()
{
try {
$this->authorize('update', $this->database);
- if ($this->database->is_public && ! $this->database->public_port) {
+ if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
- $this->database->is_public = false;
+ $this->isPublic = false;
return;
}
+ $this->syncData(true);
if ($this->database->is_public) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
+ $this->isPublic = false;
$this->database->is_public = false;
return;
@@ -182,7 +220,10 @@ public function submit()
try {
$this->authorize('update', $this->database);
$this->validate();
+ $this->syncData(true);
$this->database->save();
+ $this->database->refresh();
+ $this->syncData(false);
updateCompose($this->database);
$this->dispatch('success', 'Database saved.');
} catch (\Throwable $e) {
diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php
index b5f208941..32cf72067 100644
--- a/app/Livewire/Project/Service/EditCompose.php
+++ b/app/Livewire/Project/Service/EditCompose.php
@@ -11,6 +11,12 @@ class EditCompose extends Component
public $serviceId;
+ public ?string $dockerComposeRaw = null;
+
+ public ?string $dockerCompose = null;
+
+ public bool $isContainerLabelEscapeEnabled = false;
+
protected $listeners = [
'refreshEnvs',
'envsUpdated',
@@ -18,30 +24,45 @@ class EditCompose extends Component
];
protected $rules = [
- 'service.docker_compose_raw' => 'required',
- 'service.docker_compose' => 'required',
- 'service.is_container_label_escape_enabled' => 'required',
+ 'dockerComposeRaw' => 'required',
+ 'dockerCompose' => 'required',
+ 'isContainerLabelEscapeEnabled' => 'required',
];
public function envsUpdated()
{
- $this->dispatch('saveCompose', $this->service->docker_compose_raw);
+ $this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->refreshEnvs();
}
public function refreshEnvs()
{
$this->service = Service::ownedByCurrentTeam()->find($this->serviceId);
+ $this->syncData(false);
}
public function mount()
{
$this->service = Service::ownedByCurrentTeam()->find($this->serviceId);
+ $this->syncData(false);
+ }
+
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->service->docker_compose_raw = $this->dockerComposeRaw;
+ $this->service->docker_compose = $this->dockerCompose;
+ $this->service->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled;
+ } else {
+ $this->dockerComposeRaw = $this->service->docker_compose_raw;
+ $this->dockerCompose = $this->service->docker_compose;
+ $this->isContainerLabelEscapeEnabled = $this->service->is_container_label_escape_enabled ?? false;
+ }
}
public function validateCompose()
{
- $isValid = validateComposeFile($this->service->docker_compose_raw, $this->service->server_id);
+ $isValid = validateComposeFile($this->dockerComposeRaw, $this->service->server_id);
if ($isValid !== 'OK') {
$this->dispatch('error', "Invalid docker-compose file.\n$isValid");
} else {
@@ -52,16 +73,17 @@ public function validateCompose()
public function saveEditedCompose()
{
$this->dispatch('info', 'Saving new docker compose...');
- $this->dispatch('saveCompose', $this->service->docker_compose_raw);
+ $this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->dispatch('refreshStorages');
}
public function instantSave()
{
$this->validate([
- 'service.is_container_label_escape_enabled' => 'required',
+ 'isContainerLabelEscapeEnabled' => 'required',
]);
- $this->service->save(['is_container_label_escape_enabled' => $this->service->is_container_label_escape_enabled]);
+ $this->syncData(true);
+ $this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]);
$this->dispatch('success', 'Service updated successfully');
}
diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php
index dbbcca8f8..43d885238 100644
--- a/app/Livewire/Project/Service/EditDomain.php
+++ b/app/Livewire/Project/Service/EditDomain.php
@@ -2,12 +2,14 @@
namespace App\Livewire\Project\Service;
+use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\ServiceApplication;
use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component
{
+ use SynchronizesModelData;
public $applicationId;
public ServiceApplication $application;
@@ -18,14 +20,24 @@ class EditDomain extends Component
public $forceSaveDomains = false;
+ public ?string $fqdn = null;
+
protected $rules = [
- 'application.fqdn' => 'nullable',
- 'application.required_fqdn' => 'required|boolean',
+ 'fqdn' => 'nullable',
];
public function mount()
{
- $this->application = ServiceApplication::find($this->applicationId);
+ $this->application = ServiceApplication::query()->findOrFail($this->applicationId);
+ $this->authorize('view', $this->application);
+ $this->syncFromModel();
+ }
+
+ protected function getModelBindings(): array
+ {
+ return [
+ 'fqdn' => 'application.fqdn',
+ ];
}
public function confirmDomainUsage()
@@ -38,19 +50,22 @@ public function confirmDomainUsage()
public function submit()
{
try {
- $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
- $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
- $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
+ $this->authorize('update', $this->application);
+ $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
+ $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
+ $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
- $this->application->fqdn = $this->application->fqdn->unique()->implode(',');
- $warning = sslipDomainWarning($this->application->fqdn);
+ $this->fqdn = $domains->unique()->implode(',');
+ $warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
+ // Sync to model for domain conflict check
+ $this->syncToModel();
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@@ -67,6 +82,8 @@ public function submit()
$this->validate();
$this->application->save();
+ $this->application->refresh();
+ $this->syncData(false);
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.
Only use multiple domains if you know what you are doing.');
@@ -79,6 +96,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
+ $this->syncData(false);
}
return handleError($e, $this);
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 7f0caaba3..40539b13e 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Service;
+use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
@@ -22,7 +23,7 @@
class FileStorage extends Component
{
- use AuthorizesRequests;
+ use AuthorizesRequests, SynchronizesModelData;
public LocalFileVolume $fileStorage;
@@ -36,12 +37,16 @@ class FileStorage extends Component
public bool $isReadOnly = false;
+ public ?string $content = null;
+
+ public bool $isBasedOnGit = false;
+
protected $rules = [
'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required',
'fileStorage.mount_path' => 'required',
- 'fileStorage.content' => 'nullable',
- 'fileStorage.is_based_on_git' => 'required|boolean',
+ 'content' => 'nullable',
+ 'isBasedOnGit' => 'required|boolean',
];
public function mount()
@@ -56,6 +61,15 @@ public function mount()
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
+ $this->syncFromModel();
+ }
+
+ protected function getModelBindings(): array
+ {
+ return [
+ 'content' => 'fileStorage.content',
+ 'isBasedOnGit' => 'fileStorage.is_based_on_git',
+ ];
}
public function convertToDirectory()
@@ -82,6 +96,7 @@ public function loadStorageOnServer()
$this->authorize('update', $this->resource);
$this->fileStorage->loadStorageOnServer();
+ $this->syncFromModel();
$this->dispatch('success', 'File storage loaded from server.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -148,14 +163,16 @@ public function submit()
try {
$this->validate();
if ($this->fileStorage->is_directory) {
- $this->fileStorage->content = null;
+ $this->content = null;
}
+ $this->syncToModel();
$this->fileStorage->save();
$this->fileStorage->saveStorageOnServer();
$this->dispatch('success', 'File updated.');
} catch (\Throwable $e) {
$this->fileStorage->setRawAttributes($original);
$this->fileStorage->save();
+ $this->syncFromModel();
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php
index e37b6ad86..20358218f 100644
--- a/app/Livewire/Project/Service/ServiceApplicationView.php
+++ b/app/Livewire/Project/Service/ServiceApplicationView.php
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Service;
+use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\InstanceSettings;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -14,6 +15,7 @@
class ServiceApplicationView extends Component
{
use AuthorizesRequests;
+ use SynchronizesModelData;
public ServiceApplication $application;
@@ -29,16 +31,32 @@ class ServiceApplicationView extends Component
public $forceSaveDomains = false;
+ public ?string $humanName = null;
+
+ public ?string $description = null;
+
+ public ?string $fqdn = null;
+
+ public ?string $image = null;
+
+ public bool $excludeFromStatus = false;
+
+ public bool $isLogDrainEnabled = false;
+
+ public bool $isGzipEnabled = false;
+
+ public bool $isStripprefixEnabled = false;
+
protected $rules = [
- 'application.human_name' => 'nullable',
- 'application.description' => 'nullable',
- 'application.fqdn' => 'nullable',
- 'application.image' => 'string|nullable',
- 'application.exclude_from_status' => 'required|boolean',
+ 'humanName' => 'nullable',
+ 'description' => 'nullable',
+ 'fqdn' => 'nullable',
+ 'image' => 'string|nullable',
+ 'excludeFromStatus' => 'required|boolean',
'application.required_fqdn' => 'required|boolean',
- 'application.is_log_drain_enabled' => 'nullable|boolean',
- 'application.is_gzip_enabled' => 'nullable|boolean',
- 'application.is_stripprefix_enabled' => 'nullable|boolean',
+ 'isLogDrainEnabled' => 'nullable|boolean',
+ 'isGzipEnabled' => 'nullable|boolean',
+ 'isStripprefixEnabled' => 'nullable|boolean',
];
public function instantSave()
@@ -56,11 +74,12 @@ public function instantSaveAdvanced()
try {
$this->authorize('update', $this->application);
if (! $this->application->service->destination->server->isLogDrainEnabled()) {
- $this->application->is_log_drain_enabled = false;
+ $this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
+ $this->syncToModel();
$this->application->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
@@ -95,11 +114,26 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
+ $this->syncFromModel();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
+ protected function getModelBindings(): array
+ {
+ return [
+ 'humanName' => 'application.human_name',
+ 'description' => 'application.description',
+ 'fqdn' => 'application.fqdn',
+ 'image' => 'application.image',
+ 'excludeFromStatus' => 'application.exclude_from_status',
+ 'isLogDrainEnabled' => 'application.is_log_drain_enabled',
+ 'isGzipEnabled' => 'application.is_gzip_enabled',
+ 'isStripprefixEnabled' => 'application.is_stripprefix_enabled',
+ ];
+ }
+
public function convertToDatabase()
{
try {
@@ -146,19 +180,21 @@ public function submit()
{
try {
$this->authorize('update', $this->application);
- $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
- $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
- $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
+ $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
+ $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
+ $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
- $this->application->fqdn = $this->application->fqdn->unique()->implode(',');
- $warning = sslipDomainWarning($this->application->fqdn);
+ $this->fqdn = $domains->unique()->implode(',');
+ $warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
+ // Sync to model for domain conflict check
+ $this->syncToModel();
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@@ -175,6 +211,8 @@ public function submit()
$this->validate();
$this->application->save();
+ $this->application->refresh();
+ $this->syncFromModel();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.
Only use multiple domains if you know what you are doing.');
@@ -186,6 +224,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
+ $this->syncFromModel();
}
return handleError($e, $this);
diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php
index a0d2699ba..85cd21a7f 100644
--- a/app/Livewire/Project/Service/StackForm.php
+++ b/app/Livewire/Project/Service/StackForm.php
@@ -15,14 +15,25 @@ class StackForm extends Component
protected $listeners = ['saveCompose'];
+ // Explicit properties
+ public string $name;
+
+ public ?string $description = null;
+
+ public string $dockerComposeRaw;
+
+ public string $dockerCompose;
+
+ public ?bool $connectToDockerNetwork = null;
+
protected function rules(): array
{
$baseRules = [
- 'service.docker_compose_raw' => 'required',
- 'service.docker_compose' => 'required',
- 'service.name' => ValidationPatterns::nameRules(),
- 'service.description' => ValidationPatterns::descriptionRules(),
- 'service.connect_to_docker_network' => 'nullable',
+ 'dockerComposeRaw' => 'required',
+ 'dockerCompose' => 'required',
+ 'name' => ValidationPatterns::nameRules(),
+ 'description' => ValidationPatterns::descriptionRules(),
+ 'connectToDockerNetwork' => 'nullable',
];
// Add dynamic field rules
@@ -39,19 +50,44 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
- 'service.name.required' => 'The Name field is required.',
- 'service.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
- 'service.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
- 'service.docker_compose_raw.required' => 'The Docker Compose Raw field is required.',
- 'service.docker_compose.required' => 'The Docker Compose field is required.',
+ 'name.required' => 'The Name field is required.',
+ 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
+ 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
+ 'dockerComposeRaw.required' => 'The Docker Compose Raw field is required.',
+ 'dockerCompose.required' => 'The Docker Compose field is required.',
]
);
}
public $validationAttributes = [];
+ /**
+ * Sync data between component properties and model
+ *
+ * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
+ */
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ // Sync TO model (before save)
+ $this->service->name = $this->name;
+ $this->service->description = $this->description;
+ $this->service->docker_compose_raw = $this->dockerComposeRaw;
+ $this->service->docker_compose = $this->dockerCompose;
+ $this->service->connect_to_docker_network = $this->connectToDockerNetwork;
+ } else {
+ // Sync FROM model (on load/refresh)
+ $this->name = $this->service->name;
+ $this->description = $this->service->description;
+ $this->dockerComposeRaw = $this->service->docker_compose_raw;
+ $this->dockerCompose = $this->service->docker_compose;
+ $this->connectToDockerNetwork = $this->service->connect_to_docker_network;
+ }
+ }
+
public function mount()
{
+ $this->syncData(false);
$this->fields = collect([]);
$extraFields = $this->service->extraFields();
foreach ($extraFields as $serviceName => $fields) {
@@ -87,12 +123,13 @@ public function mount()
public function saveCompose($raw)
{
- $this->service->docker_compose_raw = $raw;
+ $this->dockerComposeRaw = $raw;
$this->submit(notify: true);
}
public function instantSave()
{
+ $this->syncData(true);
$this->service->save();
$this->dispatch('success', 'Service settings saved.');
}
@@ -101,6 +138,7 @@ public function submit($notify = true)
{
try {
$this->validate();
+ $this->syncData(true);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->service->docker_compose_raw);
diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php
index c0714fe03..c8029761d 100644
--- a/app/Livewire/Project/Shared/HealthChecks.php
+++ b/app/Livewire/Project/Shared/HealthChecks.php
@@ -2,35 +2,90 @@
namespace App\Livewire\Project\Shared;
+use App\Livewire\Concerns\SynchronizesModelData;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class HealthChecks extends Component
{
use AuthorizesRequests;
+ use SynchronizesModelData;
public $resource;
- protected $rules = [
- 'resource.health_check_enabled' => 'boolean',
- 'resource.health_check_path' => 'string',
- 'resource.health_check_port' => 'nullable|string',
- 'resource.health_check_host' => 'string',
- 'resource.health_check_method' => 'string',
- 'resource.health_check_return_code' => 'integer',
- 'resource.health_check_scheme' => 'string',
- 'resource.health_check_response_text' => 'nullable|string',
- 'resource.health_check_interval' => 'integer|min:1',
- 'resource.health_check_timeout' => 'integer|min:1',
- 'resource.health_check_retries' => 'integer|min:1',
- 'resource.health_check_start_period' => 'integer',
- 'resource.custom_healthcheck_found' => 'boolean',
+ // Explicit properties
+ public bool $healthCheckEnabled = false;
+ public string $healthCheckMethod;
+
+ public string $healthCheckScheme;
+
+ public string $healthCheckHost;
+
+ public ?string $healthCheckPort = null;
+
+ public string $healthCheckPath;
+
+ public int $healthCheckReturnCode;
+
+ public ?string $healthCheckResponseText = null;
+
+ public int $healthCheckInterval;
+
+ public int $healthCheckTimeout;
+
+ public int $healthCheckRetries;
+
+ public int $healthCheckStartPeriod;
+
+ public bool $customHealthcheckFound = false;
+
+ protected $rules = [
+ 'healthCheckEnabled' => 'boolean',
+ 'healthCheckPath' => 'string',
+ 'healthCheckPort' => 'nullable|string',
+ 'healthCheckHost' => 'string',
+ 'healthCheckMethod' => 'string',
+ 'healthCheckReturnCode' => 'integer',
+ 'healthCheckScheme' => 'string',
+ 'healthCheckResponseText' => 'nullable|string',
+ 'healthCheckInterval' => 'integer|min:1',
+ 'healthCheckTimeout' => 'integer|min:1',
+ 'healthCheckRetries' => 'integer|min:1',
+ 'healthCheckStartPeriod' => 'integer',
+ 'customHealthcheckFound' => 'boolean',
];
+ protected function getModelBindings(): array
+ {
+ return [
+ 'healthCheckEnabled' => 'resource.health_check_enabled',
+ 'healthCheckMethod' => 'resource.health_check_method',
+ 'healthCheckScheme' => 'resource.health_check_scheme',
+ 'healthCheckHost' => 'resource.health_check_host',
+ 'healthCheckPort' => 'resource.health_check_port',
+ 'healthCheckPath' => 'resource.health_check_path',
+ 'healthCheckReturnCode' => 'resource.health_check_return_code',
+ 'healthCheckResponseText' => 'resource.health_check_response_text',
+ 'healthCheckInterval' => 'resource.health_check_interval',
+ 'healthCheckTimeout' => 'resource.health_check_timeout',
+ 'healthCheckRetries' => 'resource.health_check_retries',
+ 'healthCheckStartPeriod' => 'resource.health_check_start_period',
+ 'customHealthcheckFound' => 'resource.custom_healthcheck_found',
+ ];
+ }
+
+ public function mount()
+ {
+ $this->authorize('view', $this->resource);
+ $this->syncFromModel();
+ }
+
public function instantSave()
{
$this->authorize('update', $this->resource);
+
+ $this->syncToModel();
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
}
@@ -40,6 +95,8 @@ public function submit()
try {
$this->authorize('update', $this->resource);
$this->validate();
+
+ $this->syncToModel();
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
} catch (\Throwable $e) {
@@ -51,14 +108,16 @@ public function toggleHealthcheck()
{
try {
$this->authorize('update', $this->resource);
- $wasEnabled = $this->resource->health_check_enabled;
- $this->resource->health_check_enabled = ! $this->resource->health_check_enabled;
+ $wasEnabled = $this->healthCheckEnabled;
+ $this->healthCheckEnabled = ! $this->healthCheckEnabled;
+
+ $this->syncToModel();
$this->resource->save();
- if ($this->resource->health_check_enabled && ! $wasEnabled && $this->resource->isRunning()) {
+ if ($this->healthCheckEnabled && ! $wasEnabled && $this->resource->isRunning()) {
$this->dispatch('info', 'Health check has been enabled. A restart is required to apply the new settings.');
} else {
- $this->dispatch('success', 'Health check '.($this->resource->health_check_enabled ? 'enabled' : 'disabled').'.');
+ $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Project/Shared/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php
index 196badec8..0b3840289 100644
--- a/app/Livewire/Project/Shared/ResourceLimits.php
+++ b/app/Livewire/Project/Shared/ResourceLimits.php
@@ -11,52 +11,105 @@ class ResourceLimits extends Component
public $resource;
+ // Explicit properties
+ public ?string $limitsCpus = null;
+
+ public ?string $limitsCpuset = null;
+
+ public ?int $limitsCpuShares = null;
+
+ public string $limitsMemory;
+
+ public string $limitsMemorySwap;
+
+ public int $limitsMemorySwappiness;
+
+ public string $limitsMemoryReservation;
+
protected $rules = [
- 'resource.limits_memory' => 'required|string',
- 'resource.limits_memory_swap' => 'required|string',
- 'resource.limits_memory_swappiness' => 'required|integer|min:0|max:100',
- 'resource.limits_memory_reservation' => 'required|string',
- 'resource.limits_cpus' => 'nullable',
- 'resource.limits_cpuset' => 'nullable',
- 'resource.limits_cpu_shares' => 'nullable',
+ 'limitsMemory' => 'required|string',
+ 'limitsMemorySwap' => 'required|string',
+ 'limitsMemorySwappiness' => 'required|integer|min:0|max:100',
+ 'limitsMemoryReservation' => 'required|string',
+ 'limitsCpus' => 'nullable',
+ 'limitsCpuset' => 'nullable',
+ 'limitsCpuShares' => 'nullable',
];
protected $validationAttributes = [
- 'resource.limits_memory' => 'memory',
- 'resource.limits_memory_swap' => 'swap',
- 'resource.limits_memory_swappiness' => 'swappiness',
- 'resource.limits_memory_reservation' => 'reservation',
- 'resource.limits_cpus' => 'cpus',
- 'resource.limits_cpuset' => 'cpuset',
- 'resource.limits_cpu_shares' => 'cpu shares',
+ 'limitsMemory' => 'memory',
+ 'limitsMemorySwap' => 'swap',
+ 'limitsMemorySwappiness' => 'swappiness',
+ 'limitsMemoryReservation' => 'reservation',
+ 'limitsCpus' => 'cpus',
+ 'limitsCpuset' => 'cpuset',
+ 'limitsCpuShares' => 'cpu shares',
];
+ /**
+ * Sync data between component properties and model
+ *
+ * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
+ */
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ // Sync TO model (before save)
+ $this->resource->limits_cpus = $this->limitsCpus;
+ $this->resource->limits_cpuset = $this->limitsCpuset;
+ $this->resource->limits_cpu_shares = $this->limitsCpuShares;
+ $this->resource->limits_memory = $this->limitsMemory;
+ $this->resource->limits_memory_swap = $this->limitsMemorySwap;
+ $this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness;
+ $this->resource->limits_memory_reservation = $this->limitsMemoryReservation;
+ } else {
+ // Sync FROM model (on load/refresh)
+ $this->limitsCpus = $this->resource->limits_cpus;
+ $this->limitsCpuset = $this->resource->limits_cpuset;
+ $this->limitsCpuShares = $this->resource->limits_cpu_shares;
+ $this->limitsMemory = $this->resource->limits_memory;
+ $this->limitsMemorySwap = $this->resource->limits_memory_swap;
+ $this->limitsMemorySwappiness = $this->resource->limits_memory_swappiness;
+ $this->limitsMemoryReservation = $this->resource->limits_memory_reservation;
+ }
+ }
+
+ public function mount()
+ {
+ $this->syncData(false);
+ }
+
public function submit()
{
try {
$this->authorize('update', $this->resource);
- if (! $this->resource->limits_memory) {
- $this->resource->limits_memory = '0';
+
+ // Apply default values to properties
+ if (! $this->limitsMemory) {
+ $this->limitsMemory = '0';
}
- if (! $this->resource->limits_memory_swap) {
- $this->resource->limits_memory_swap = '0';
+ if (! $this->limitsMemorySwap) {
+ $this->limitsMemorySwap = '0';
}
- if (is_null($this->resource->limits_memory_swappiness)) {
- $this->resource->limits_memory_swappiness = '60';
+ if (is_null($this->limitsMemorySwappiness)) {
+ $this->limitsMemorySwappiness = 60;
}
- if (! $this->resource->limits_memory_reservation) {
- $this->resource->limits_memory_reservation = '0';
+ if (! $this->limitsMemoryReservation) {
+ $this->limitsMemoryReservation = '0';
}
- if (! $this->resource->limits_cpus) {
- $this->resource->limits_cpus = '0';
+ if (! $this->limitsCpus) {
+ $this->limitsCpus = '0';
}
- if ($this->resource->limits_cpuset === '') {
- $this->resource->limits_cpuset = null;
+ if ($this->limitsCpuset === '') {
+ $this->limitsCpuset = null;
}
- if (is_null($this->resource->limits_cpu_shares)) {
- $this->resource->limits_cpu_shares = 1024;
+ if (is_null($this->limitsCpuShares)) {
+ $this->limitsCpuShares = 1024;
}
+
$this->validate();
+
+ $this->syncData(true);
$this->resource->save();
$this->dispatch('success', 'Resource limits updated.');
} catch (\Throwable $e) {
diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php
index 4f57cbfa6..5970ec904 100644
--- a/app/Livewire/Project/Shared/Storages/Show.php
+++ b/app/Livewire/Project/Shared/Storages/Show.php
@@ -25,20 +25,48 @@ class Show extends Component
public ?string $startedAt = null;
+ // Explicit properties
+ public string $name;
+
+ public string $mountPath;
+
+ public ?string $hostPath = null;
+
protected $rules = [
- 'storage.name' => 'required|string',
- 'storage.mount_path' => 'required|string',
- 'storage.host_path' => 'string|nullable',
+ 'name' => 'required|string',
+ 'mountPath' => 'required|string',
+ 'hostPath' => 'string|nullable',
];
protected $validationAttributes = [
'name' => 'name',
- 'mount_path' => 'mount',
- 'host_path' => 'host',
+ 'mountPath' => 'mount',
+ 'hostPath' => 'host',
];
+ /**
+ * Sync data between component properties and model
+ *
+ * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
+ */
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ // Sync TO model (before save)
+ $this->storage->name = $this->name;
+ $this->storage->mount_path = $this->mountPath;
+ $this->storage->host_path = $this->hostPath;
+ } else {
+ // Sync FROM model (on load/refresh)
+ $this->name = $this->storage->name;
+ $this->mountPath = $this->storage->mount_path;
+ $this->hostPath = $this->storage->host_path;
+ }
+ }
+
public function mount()
{
+ $this->syncData(false);
$this->isReadOnly = $this->storage->isReadOnlyVolume();
}
@@ -47,6 +75,7 @@ public function submit()
$this->authorize('update', $this->resource);
$this->validate();
+ $this->syncData(true);
$this->storage->save();
$this->dispatch('success', 'Storage updated successfully');
}
diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php
index 2ff06c349..9928cfe97 100644
--- a/app/Livewire/Security/PrivateKey/Show.php
+++ b/app/Livewire/Security/PrivateKey/Show.php
@@ -13,15 +13,24 @@ class Show extends Component
public PrivateKey $private_key;
+ // Explicit properties
+ public string $name;
+
+ public ?string $description = null;
+
+ public string $privateKeyValue;
+
+ public bool $isGitRelated = false;
+
public $public_key = 'Loading...';
protected function rules(): array
{
return [
- 'private_key.name' => ValidationPatterns::nameRules(),
- 'private_key.description' => ValidationPatterns::descriptionRules(),
- 'private_key.private_key' => 'required|string',
- 'private_key.is_git_related' => 'nullable|boolean',
+ 'name' => ValidationPatterns::nameRules(),
+ 'description' => ValidationPatterns::descriptionRules(),
+ 'privateKeyValue' => 'required|string',
+ 'isGitRelated' => 'nullable|boolean',
];
}
@@ -30,25 +39,48 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
- 'private_key.name.required' => 'The Name field is required.',
- 'private_key.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
- 'private_key.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
- 'private_key.private_key.required' => 'The Private Key field is required.',
- 'private_key.private_key.string' => 'The Private Key must be a valid string.',
+ 'name.required' => 'The Name field is required.',
+ 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
+ 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
+ 'privateKeyValue.required' => 'The Private Key field is required.',
+ 'privateKeyValue.string' => 'The Private Key must be a valid string.',
]
);
}
protected $validationAttributes = [
- 'private_key.name' => 'name',
- 'private_key.description' => 'description',
- 'private_key.private_key' => 'private key',
+ 'name' => 'name',
+ 'description' => 'description',
+ 'privateKeyValue' => 'private key',
];
+ /**
+ * Sync data between component properties and model
+ *
+ * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
+ */
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ // Sync TO model (before save)
+ $this->private_key->name = $this->name;
+ $this->private_key->description = $this->description;
+ $this->private_key->private_key = $this->privateKeyValue;
+ $this->private_key->is_git_related = $this->isGitRelated;
+ } else {
+ // Sync FROM model (on load/refresh)
+ $this->name = $this->private_key->name;
+ $this->description = $this->private_key->description;
+ $this->privateKeyValue = $this->private_key->private_key;
+ $this->isGitRelated = $this->private_key->is_git_related;
+ }
+ }
+
public function mount()
{
try {
$this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail();
+ $this->syncData(false);
} catch (\Throwable) {
abort(404);
}
@@ -81,6 +113,10 @@ public function changePrivateKey()
{
try {
$this->authorize('update', $this->private_key);
+
+ $this->validate();
+
+ $this->syncData(true);
$this->private_key->updatePrivateKey([
'private_key' => formatPrivateKey($this->private_key->private_key),
]);
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index a77c5df78..ca5c588f8 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -290,7 +290,7 @@ private function loadHetznerData(string $token)
}
}
- private function getCpuVendorInfo(array $serverType): string|null
+ private function getCpuVendorInfo(array $serverType): ?string
{
$name = strtolower($serverType['name'] ?? '');
diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php
index 5ef559862..bc7e9bde4 100644
--- a/app/Livewire/Server/Proxy.php
+++ b/app/Livewire/Server/Proxy.php
@@ -22,6 +22,8 @@ class Proxy extends Component
public ?string $redirectUrl = null;
+ public bool $generateExactLabels = false;
+
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -33,7 +35,7 @@ public function getListeners()
}
protected $rules = [
- 'server.settings.generate_exact_labels' => 'required|boolean',
+ 'generateExactLabels' => 'required|boolean',
];
public function mount()
@@ -41,6 +43,16 @@ public function mount()
$this->selectedProxy = $this->server->proxyType();
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
+ $this->syncData(false);
+ }
+
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->server->settings->generate_exact_labels = $this->generateExactLabels;
+ } else {
+ $this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false;
+ }
}
public function getConfigurationFilePathProperty()
@@ -75,6 +87,7 @@ public function instantSave()
try {
$this->authorize('update', $this->server);
$this->validate();
+ $this->syncData(true);
$this->server->settings->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index 9ad5444b9..351407dac 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -34,32 +34,60 @@ class Change extends Component
public ?GithubApp $github_app = null;
+ // Explicit properties
public string $name;
- public bool $is_system_wide;
+ public ?string $organization = null;
+
+ public string $apiUrl;
+
+ public string $htmlUrl;
+
+ public string $customUser;
+
+ public int $customPort;
+
+ public int $appId;
+
+ public int $installationId;
+
+ public string $clientId;
+
+ public string $clientSecret;
+
+ public string $webhookSecret;
+
+ public bool $isSystemWide;
+
+ public int $privateKeyId;
+
+ public ?string $contents = null;
+
+ public ?string $metadata = null;
+
+ public ?string $pullRequests = null;
public $applications;
public $privateKeys;
protected $rules = [
- 'github_app.name' => 'required|string',
- 'github_app.organization' => 'nullable|string',
- 'github_app.api_url' => 'required|string',
- 'github_app.html_url' => 'required|string',
- 'github_app.custom_user' => 'required|string',
- 'github_app.custom_port' => 'required|int',
- 'github_app.app_id' => 'required|int',
- 'github_app.installation_id' => 'required|int',
- 'github_app.client_id' => 'required|string',
- 'github_app.client_secret' => 'required|string',
- 'github_app.webhook_secret' => 'required|string',
- 'github_app.is_system_wide' => 'required|bool',
- 'github_app.contents' => 'nullable|string',
- 'github_app.metadata' => 'nullable|string',
- 'github_app.pull_requests' => 'nullable|string',
- 'github_app.administration' => 'nullable|string',
- 'github_app.private_key_id' => 'required|int',
+ 'name' => 'required|string',
+ 'organization' => 'nullable|string',
+ 'apiUrl' => 'required|string',
+ 'htmlUrl' => 'required|string',
+ 'customUser' => 'required|string',
+ 'customPort' => 'required|int',
+ 'appId' => 'required|int',
+ 'installationId' => 'required|int',
+ 'clientId' => 'required|string',
+ 'clientSecret' => 'required|string',
+ 'webhookSecret' => 'required|string',
+ 'isSystemWide' => 'required|bool',
+ 'contents' => 'nullable|string',
+ 'metadata' => 'nullable|string',
+ 'pullRequests' => 'nullable|string',
+ 'privateKeyId' => 'required|int',
];
public function boot()
@@ -69,6 +97,52 @@ public function boot()
}
}
+ /**
+ * Sync data between component properties and model
+ *
+ * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
+ */
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ // Sync TO model (before save)
+ $this->github_app->name = $this->name;
+ $this->github_app->organization = $this->organization;
+ $this->github_app->api_url = $this->apiUrl;
+ $this->github_app->html_url = $this->htmlUrl;
+ $this->github_app->custom_user = $this->customUser;
+ $this->github_app->custom_port = $this->customPort;
+ $this->github_app->app_id = $this->appId;
+ $this->github_app->installation_id = $this->installationId;
+ $this->github_app->client_id = $this->clientId;
+ $this->github_app->client_secret = $this->clientSecret;
+ $this->github_app->webhook_secret = $this->webhookSecret;
+ $this->github_app->is_system_wide = $this->isSystemWide;
+ $this->github_app->private_key_id = $this->privateKeyId;
+ $this->github_app->contents = $this->contents;
+ $this->github_app->metadata = $this->metadata;
+ $this->github_app->pull_requests = $this->pullRequests;
+ } else {
+ // Sync FROM model (on load/refresh)
+ $this->name = $this->github_app->name;
+ $this->organization = $this->github_app->organization;
+ $this->apiUrl = $this->github_app->api_url;
+ $this->htmlUrl = $this->github_app->html_url;
+ $this->customUser = $this->github_app->custom_user;
+ $this->customPort = $this->github_app->custom_port;
+ $this->appId = $this->github_app->app_id;
+ $this->installationId = $this->github_app->installation_id;
+ $this->clientId = $this->github_app->client_id;
+ $this->clientSecret = $this->github_app->client_secret;
+ $this->webhookSecret = $this->github_app->webhook_secret;
+ $this->isSystemWide = $this->github_app->is_system_wide;
+ $this->privateKeyId = $this->github_app->private_key_id;
+ $this->contents = $this->github_app->contents;
+ $this->metadata = $this->github_app->metadata;
+ $this->pullRequests = $this->github_app->pull_requests;
+ }
+ }
+
public function checkPermissions()
{
try {
@@ -126,6 +200,10 @@ public function mount()
$this->applications = $this->github_app->applications;
$settings = instanceSettings();
+ // Sync data from model to properties
+ $this->syncData(false);
+
+ // Override name with kebab case for display
$this->name = str($this->github_app->name)->kebab();
$this->fqdn = $settings->fqdn;
@@ -247,21 +325,9 @@ public function submit()
$this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
- $this->validate([
- 'github_app.name' => 'required|string',
- 'github_app.organization' => 'nullable|string',
- 'github_app.api_url' => 'required|string',
- 'github_app.html_url' => 'required|string',
- 'github_app.custom_user' => 'required|string',
- 'github_app.custom_port' => 'required|int',
- 'github_app.app_id' => 'required|int',
- 'github_app.installation_id' => 'required|int',
- 'github_app.client_id' => 'required|string',
- 'github_app.client_secret' => 'required|string',
- 'github_app.webhook_secret' => 'required|string',
- 'github_app.is_system_wide' => 'required|bool',
- 'github_app.private_key_id' => 'required|int',
- ]);
+ $this->validate();
+
+ $this->syncData(true);
$this->github_app->save();
$this->dispatch('success', 'Github App updated.');
} catch (\Throwable $e) {
@@ -286,6 +352,8 @@ public function instantSave()
$this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
+
+ $this->syncData(true);
$this->github_app->save();
$this->dispatch('success', 'Github App updated.');
} catch (\Throwable $e) {
diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php
index 9438b7727..d97550693 100644
--- a/app/Livewire/Storage/Form.php
+++ b/app/Livewire/Storage/Form.php
@@ -14,17 +14,34 @@ class Form extends Component
public S3Storage $storage;
+ // Explicit properties
+ public ?string $name = null;
+
+ public ?string $description = null;
+
+ public string $endpoint;
+
+ public string $bucket;
+
+ public string $region;
+
+ public string $key;
+
+ public string $secret;
+
+ public ?bool $isUsable = null;
+
protected function rules(): array
{
return [
- 'storage.is_usable' => 'nullable|boolean',
- 'storage.name' => ValidationPatterns::nameRules(required: false),
- 'storage.description' => ValidationPatterns::descriptionRules(),
- 'storage.region' => 'required|max:255',
- 'storage.key' => 'required|max:255',
- 'storage.secret' => 'required|max:255',
- 'storage.bucket' => 'required|max:255',
- 'storage.endpoint' => 'required|url|max:255',
+ 'isUsable' => 'nullable|boolean',
+ 'name' => ValidationPatterns::nameRules(required: false),
+ 'description' => ValidationPatterns::descriptionRules(),
+ 'region' => 'required|max:255',
+ 'key' => 'required|max:255',
+ 'secret' => 'required|max:255',
+ 'bucket' => 'required|max:255',
+ 'endpoint' => 'required|url|max:255',
];
}
@@ -33,34 +50,69 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
- 'storage.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
- 'storage.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
- 'storage.region.required' => 'The Region field is required.',
- 'storage.region.max' => 'The Region may not be greater than 255 characters.',
- 'storage.key.required' => 'The Access Key field is required.',
- 'storage.key.max' => 'The Access Key may not be greater than 255 characters.',
- 'storage.secret.required' => 'The Secret Key field is required.',
- 'storage.secret.max' => 'The Secret Key may not be greater than 255 characters.',
- 'storage.bucket.required' => 'The Bucket field is required.',
- 'storage.bucket.max' => 'The Bucket may not be greater than 255 characters.',
- 'storage.endpoint.required' => 'The Endpoint field is required.',
- 'storage.endpoint.url' => 'The Endpoint must be a valid URL.',
- 'storage.endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
+ 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
+ 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
+ 'region.required' => 'The Region field is required.',
+ 'region.max' => 'The Region may not be greater than 255 characters.',
+ 'key.required' => 'The Access Key field is required.',
+ 'key.max' => 'The Access Key may not be greater than 255 characters.',
+ 'secret.required' => 'The Secret Key field is required.',
+ 'secret.max' => 'The Secret Key may not be greater than 255 characters.',
+ 'bucket.required' => 'The Bucket field is required.',
+ 'bucket.max' => 'The Bucket may not be greater than 255 characters.',
+ 'endpoint.required' => 'The Endpoint field is required.',
+ 'endpoint.url' => 'The Endpoint must be a valid URL.',
+ 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
);
}
protected $validationAttributes = [
- 'storage.is_usable' => 'Is Usable',
- 'storage.name' => 'Name',
- 'storage.description' => 'Description',
- 'storage.region' => 'Region',
- 'storage.key' => 'Key',
- 'storage.secret' => 'Secret',
- 'storage.bucket' => 'Bucket',
- 'storage.endpoint' => 'Endpoint',
+ 'isUsable' => 'Is Usable',
+ 'name' => 'Name',
+ 'description' => 'Description',
+ 'region' => 'Region',
+ 'key' => 'Key',
+ 'secret' => 'Secret',
+ 'bucket' => 'Bucket',
+ 'endpoint' => 'Endpoint',
];
+ /**
+ * Sync data between component properties and model
+ *
+ * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
+ */
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ // Sync TO model (before save)
+ $this->storage->name = $this->name;
+ $this->storage->description = $this->description;
+ $this->storage->endpoint = $this->endpoint;
+ $this->storage->bucket = $this->bucket;
+ $this->storage->region = $this->region;
+ $this->storage->key = $this->key;
+ $this->storage->secret = $this->secret;
+ $this->storage->is_usable = $this->isUsable;
+ } else {
+ // Sync FROM model (on load/refresh)
+ $this->name = $this->storage->name;
+ $this->description = $this->storage->description;
+ $this->endpoint = $this->storage->endpoint;
+ $this->bucket = $this->storage->bucket;
+ $this->region = $this->storage->region;
+ $this->key = $this->storage->key;
+ $this->secret = $this->storage->secret;
+ $this->isUsable = $this->storage->is_usable;
+ }
+ }
+
+ public function mount()
+ {
+ $this->syncData(false);
+ }
+
public function testConnection()
{
try {
@@ -94,6 +146,9 @@ public function submit()
DB::transaction(function () {
$this->validate();
+
+ // Sync properties to model before saving
+ $this->syncData(true);
$this->storage->save();
// Test connection with new values - if this fails, transaction will rollback
@@ -103,12 +158,16 @@ public function submit()
$this->storage->is_usable = true;
$this->storage->unusable_email_sent = false;
$this->storage->save();
+
+ // Update local property to reflect success
+ $this->isUsable = true;
});
$this->dispatch('success', 'Storage settings updated and connection verified.');
} catch (\Throwable $e) {
// Refresh the model to revert UI to database values after rollback
$this->storage->refresh();
+ $this->syncData(false);
return handleError($e, $this);
}
diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php
index 8b9b70e14..e4daad311 100644
--- a/app/Livewire/Team/Index.php
+++ b/app/Livewire/Team/Index.php
@@ -18,11 +18,16 @@ class Index extends Component
public Team $team;
+ // Explicit properties
+ public string $name;
+
+ public ?string $description = null;
+
protected function rules(): array
{
return [
- 'team.name' => ValidationPatterns::nameRules(),
- 'team.description' => ValidationPatterns::descriptionRules(),
+ 'name' => ValidationPatterns::nameRules(),
+ 'description' => ValidationPatterns::descriptionRules(),
];
}
@@ -31,21 +36,40 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
- 'team.name.required' => 'The Name field is required.',
- 'team.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
- 'team.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
+ 'name.required' => 'The Name field is required.',
+ 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
+ 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
]
);
}
protected $validationAttributes = [
- 'team.name' => 'name',
- 'team.description' => 'description',
+ 'name' => 'name',
+ 'description' => 'description',
];
+ /**
+ * Sync data between component properties and model
+ *
+ * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
+ */
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ // Sync TO model (before save)
+ $this->team->name = $this->name;
+ $this->team->description = $this->description;
+ } else {
+ // Sync FROM model (on load/refresh)
+ $this->name = $this->team->name;
+ $this->description = $this->team->description;
+ }
+ }
+
public function mount()
{
$this->team = currentTeam();
+ $this->syncData(false);
if (auth()->user()->isAdminFromSession()) {
$this->invitations = TeamInvitation::whereTeamId(currentTeam()->id)->get();
@@ -62,6 +86,7 @@ public function submit()
$this->validate();
try {
$this->authorize('update', $this->team);
+ $this->syncData(true);
$this->team->save();
refreshSession();
$this->dispatch('success', 'Team updated.');
diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php
index 88f858ec9..eb38d84af 100644
--- a/app/View/Components/Forms/Checkbox.php
+++ b/app/View/Components/Forms/Checkbox.php
@@ -6,9 +6,14 @@
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
+use Visus\Cuid2\Cuid2;
class Checkbox extends Component
{
+ public ?string $modelBinding = null;
+
+ public ?string $htmlId = null;
+
/**
* Create a new component instance.
*/
@@ -47,6 +52,18 @@ public function __construct(
*/
public function render(): View|Closure|string
{
+ // Store original ID for wire:model binding (property name)
+ $this->modelBinding = $this->id;
+
+ // Generate unique HTML ID by adding random suffix
+ // This prevents duplicate IDs when multiple forms are on the same page
+ if ($this->id) {
+ $uniqueSuffix = new Cuid2;
+ $this->htmlId = $this->id.'-'.$uniqueSuffix;
+ } else {
+ $this->htmlId = $this->id;
+ }
+
return view('components.forms.checkbox');
}
}
diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php
index 33e264e37..3b7a9ee34 100644
--- a/app/View/Components/Forms/Datalist.php
+++ b/app/View/Components/Forms/Datalist.php
@@ -10,6 +10,10 @@
class Datalist extends Component
{
+ public ?string $modelBinding = null;
+
+ public ?string $htmlId = null;
+
/**
* Create a new component instance.
*/
@@ -47,11 +51,27 @@ public function __construct(
*/
public function render(): View|Closure|string
{
+ // Store original ID for wire:model binding (property name)
+ $this->modelBinding = $this->id;
+
if (is_null($this->id)) {
$this->id = new Cuid2;
+ // Don't create wire:model binding for auto-generated IDs
+ $this->modelBinding = 'null';
}
+
+ // Generate unique HTML ID by adding random suffix
+ // This prevents duplicate IDs when multiple forms are on the same page
+ if ($this->modelBinding && $this->modelBinding !== 'null') {
+ // Use original ID with random suffix for uniqueness
+ $uniqueSuffix = new Cuid2;
+ $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
+ } else {
+ $this->htmlId = (string) $this->id;
+ }
+
if (is_null($this->name)) {
- $this->name = $this->id;
+ $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
return view('components.forms.datalist');
diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php
index 83c98c0df..5ed347f42 100644
--- a/app/View/Components/Forms/Input.php
+++ b/app/View/Components/Forms/Input.php
@@ -10,6 +10,10 @@
class Input extends Component
{
+ public ?string $modelBinding = null;
+
+ public ?string $htmlId = null;
+
public function __construct(
public ?string $id = null,
public ?string $name = null,
@@ -43,11 +47,26 @@ public function __construct(
public function render(): View|Closure|string
{
+ // Store original ID for wire:model binding (property name)
+ $this->modelBinding = $this->id;
+
if (is_null($this->id)) {
$this->id = new Cuid2;
+ // Don't create wire:model binding for auto-generated IDs
+ $this->modelBinding = 'null';
}
+ // Generate unique HTML ID by adding random suffix
+ // This prevents duplicate IDs when multiple forms are on the same page
+ if ($this->modelBinding && $this->modelBinding !== 'null') {
+ // Use original ID with random suffix for uniqueness
+ $uniqueSuffix = new Cuid2;
+ $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
+ } else {
+ $this->htmlId = (string) $this->id;
+ }
+
if (is_null($this->name)) {
- $this->name = $this->id;
+ $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
if ($this->type === 'password') {
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php
index 49b69136b..026e3ba8c 100644
--- a/app/View/Components/Forms/Select.php
+++ b/app/View/Components/Forms/Select.php
@@ -10,6 +10,10 @@
class Select extends Component
{
+ public ?string $modelBinding = null;
+
+ public ?string $htmlId = null;
+
/**
* Create a new component instance.
*/
@@ -40,11 +44,27 @@ public function __construct(
*/
public function render(): View|Closure|string
{
+ // Store original ID for wire:model binding (property name)
+ $this->modelBinding = $this->id;
+
if (is_null($this->id)) {
$this->id = new Cuid2;
+ // Don't create wire:model binding for auto-generated IDs
+ $this->modelBinding = 'null';
}
+
+ // Generate unique HTML ID by adding random suffix
+ // This prevents duplicate IDs when multiple forms are on the same page
+ if ($this->modelBinding && $this->modelBinding !== 'null') {
+ // Use original ID with random suffix for uniqueness
+ $uniqueSuffix = new Cuid2;
+ $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
+ } else {
+ $this->htmlId = (string) $this->id;
+ }
+
if (is_null($this->name)) {
- $this->name = $this->id;
+ $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
return view('components.forms.select');
diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php
index abf98e6df..a5303b947 100644
--- a/app/View/Components/Forms/Textarea.php
+++ b/app/View/Components/Forms/Textarea.php
@@ -10,6 +10,10 @@
class Textarea extends Component
{
+ public ?string $modelBinding = null;
+
+ public ?string $htmlId = null;
+
/**
* Create a new component instance.
*/
@@ -54,11 +58,27 @@ public function __construct(
*/
public function render(): View|Closure|string
{
+ // Store original ID for wire:model binding (property name)
+ $this->modelBinding = $this->id;
+
if (is_null($this->id)) {
$this->id = new Cuid2;
+ // Don't create wire:model binding for auto-generated IDs
+ $this->modelBinding = 'null';
}
+
+ // Generate unique HTML ID by adding random suffix
+ // This prevents duplicate IDs when multiple forms are on the same page
+ if ($this->modelBinding && $this->modelBinding !== 'null') {
+ // Use original ID with random suffix for uniqueness
+ $uniqueSuffix = new Cuid2;
+ $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
+ } else {
+ $this->htmlId = (string) $this->id;
+ }
+
if (is_null($this->name)) {
- $this->name = $this->id;
+ $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
// $this->label = Str::title($this->label);
diff --git a/config/livewire.php b/config/livewire.php
index 02725e944..bd3733076 100644
--- a/config/livewire.php
+++ b/config/livewire.php
@@ -90,7 +90,7 @@
|
*/
- 'legacy_model_binding' => true,
+ 'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php
index 868f657f6..b291759a8 100644
--- a/resources/views/components/forms/checkbox.blade.php
+++ b/resources/views/components/forms/checkbox.blade.php
@@ -32,14 +32,14 @@
merge(['class' => $defaultClass]) }}
wire:loading.attr="disabled"
wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}'
- wire:model={{ $id }} @if ($checked) checked @endif />
+ wire:model={{ $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif />
@else
@if ($domValue)
merge(['class' => $defaultClass]) }}
- value={{ $domValue }} @if ($checked) checked @endif />
+ value={{ $domValue }} id="{{ $htmlId }}" @if ($checked) checked @endif />
@else
merge(['class' => $defaultClass]) }}
- wire:model={{ $value ?? $id }} @if ($checked) checked @endif />
+ wire:model={{ $value ?? $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif />
@endif
@endif
diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php
index 510f4adcc..5bb12aa8d 100644
--- a/resources/views/components/forms/datalist.blade.php
+++ b/resources/views/components/forms/datalist.blade.php
@@ -16,7 +16,7 @@
A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.