From fbaa5eb3696f9588c0a78623988e9c07c384a115 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 4 Nov 2025 08:43:33 +0100
Subject: [PATCH 1/3] feat: Update ApplicationSetting model to include
additional boolean casts
- Changed `$cast` to `$casts` in ApplicationSetting model to enable proper boolean casting for new fields.
- Added boolean fields: `is_spa`, `is_build_server_enabled`, `is_preserve_repository_enabled`, `is_container_label_escape_enabled`, `is_container_label_readonly_enabled`, and `use_build_secrets`.
fix: Update Livewire component to reflect new property names
- Updated references in the Livewire component for the new camelCase property names.
- Adjusted bindings and IDs for consistency with the updated model.
test: Add unit tests for ApplicationSetting boolean casting
- Created tests to verify boolean casting for `is_static` and other boolean fields in ApplicationSetting.
- Ensured all boolean fields are correctly defined in the casts array.
test: Implement tests for SynchronizesModelData trait
- Added tests to verify the functionality of the SynchronizesModelData trait, ensuring it correctly syncs properties between the component and the model.
- Included tests for handling non-existent properties gracefully.
---
app/Livewire/Project/Application/General.php | 525 ++++++++++--------
app/Models/ApplicationSetting.php | 8 +-
.../project/application/general.blade.php | 102 ++--
.../Unit/ApplicationSettingStaticCastTest.php | 105 ++++
tests/Unit/SynchronizesModelDataTest.php | 163 ++++++
5 files changed, 615 insertions(+), 288 deletions(-)
create mode 100644 tests/Unit/ApplicationSettingStaticCastTest.php
create mode 100644 tests/Unit/SynchronizesModelDataTest.php
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 8e8add430..03db8b1c8 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -3,7 +3,6 @@
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;
@@ -15,7 +14,6 @@
class General extends Component
{
use AuthorizesRequests;
- use SynchronizesModelData;
public string $applicationId;
@@ -29,85 +27,83 @@ class General extends Component
public ?string $fqdn = null;
- public string $git_repository;
+ public string $gitRepository;
- public string $git_branch;
+ public string $gitBranch;
- public ?string $git_commit_sha = null;
+ public ?string $gitCommitSha = null;
- public ?string $install_command = null;
+ public ?string $installCommand = null;
- public ?string $build_command = null;
+ public ?string $buildCommand = null;
- public ?string $start_command = null;
+ public ?string $startCommand = null;
- public string $build_pack;
+ public string $buildPack;
- public string $static_image;
+ public string $staticImage;
- public string $base_directory;
+ public string $baseDirectory;
- public ?string $publish_directory = null;
+ public ?string $publishDirectory = null;
- public ?string $ports_exposes = null;
+ public ?string $portsExposes = null;
- public ?string $ports_mappings = null;
+ public ?string $portsMappings = null;
- public ?string $custom_network_aliases = null;
+ public ?string $customNetworkAliases = null;
public ?string $dockerfile = null;
- public ?string $dockerfile_location = null;
+ public ?string $dockerfileLocation = null;
- public ?string $dockerfile_target_build = null;
+ public ?string $dockerfileTargetBuild = null;
- public ?string $docker_registry_image_name = null;
+ public ?string $dockerRegistryImageName = null;
- public ?string $docker_registry_image_tag = null;
+ public ?string $dockerRegistryImageTag = null;
- public ?string $docker_compose_location = null;
+ public ?string $dockerComposeLocation = null;
- public ?string $docker_compose = null;
+ public ?string $dockerCompose = null;
- public ?string $docker_compose_raw = null;
+ public ?string $dockerComposeRaw = null;
- public ?string $docker_compose_custom_start_command = null;
+ public ?string $dockerComposeCustomStartCommand = null;
- public ?string $docker_compose_custom_build_command = null;
+ public ?string $dockerComposeCustomBuildCommand = null;
- public ?string $custom_labels = null;
+ public ?string $customDockerRunOptions = null;
- public ?string $custom_docker_run_options = null;
+ public ?string $preDeploymentCommand = null;
- public ?string $pre_deployment_command = null;
+ public ?string $preDeploymentCommandContainer = null;
- public ?string $pre_deployment_command_container = null;
+ public ?string $postDeploymentCommand = null;
- public ?string $post_deployment_command = null;
+ public ?string $postDeploymentCommandContainer = null;
- public ?string $post_deployment_command_container = null;
+ public ?string $customNginxConfiguration = null;
- public ?string $custom_nginx_configuration = null;
+ public bool $isStatic = false;
- public bool $is_static = false;
+ public bool $isSpa = false;
- public bool $is_spa = false;
+ public bool $isBuildServerEnabled = false;
- public bool $is_build_server_enabled = false;
+ public bool $isPreserveRepositoryEnabled = false;
- public bool $is_preserve_repository_enabled = false;
+ public bool $isContainerLabelEscapeEnabled = true;
- public bool $is_container_label_escape_enabled = true;
+ public bool $isContainerLabelReadonlyEnabled = false;
- public bool $is_container_label_readonly_enabled = false;
+ public bool $isHttpBasicAuthEnabled = false;
- public bool $is_http_basic_auth_enabled = false;
+ public ?string $httpBasicAuthUsername = null;
- public ?string $http_basic_auth_username = null;
+ public ?string $httpBasicAuthPassword = null;
- public ?string $http_basic_auth_password = null;
-
- public ?string $watch_paths = null;
+ public ?string $watchPaths = null;
public string $redirect;
@@ -141,46 +137,46 @@ protected function rules(): array
'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',
+ 'gitRepository' => 'required',
+ 'gitBranch' => 'required',
+ 'gitCommitSha' => 'nullable',
+ 'installCommand' => 'nullable',
+ 'buildCommand' => 'nullable',
+ 'startCommand' => 'nullable',
+ 'buildPack' => 'required',
+ 'staticImage' => 'required',
+ 'baseDirectory' => 'required',
+ 'publishDirectory' => 'nullable',
+ 'portsExposes' => 'required',
+ 'portsMappings' => 'nullable',
+ 'customNetworkAliases' => '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',
+ 'dockerRegistryImageName' => 'nullable',
+ 'dockerRegistryImageTag' => 'nullable',
+ 'dockerfileLocation' => 'nullable',
+ 'dockerComposeLocation' => 'nullable',
+ 'dockerCompose' => 'nullable',
+ 'dockerComposeRaw' => 'nullable',
+ 'dockerfileTargetBuild' => 'nullable',
+ 'dockerComposeCustomStartCommand' => 'nullable',
+ 'dockerComposeCustomBuildCommand' => 'nullable',
+ 'customLabels' => 'nullable',
+ 'customDockerRunOptions' => 'nullable',
+ 'preDeploymentCommand' => 'nullable',
+ 'preDeploymentCommandContainer' => 'nullable',
+ 'postDeploymentCommand' => 'nullable',
+ 'postDeploymentCommandContainer' => 'nullable',
+ 'customNginxConfiguration' => 'nullable',
+ 'isStatic' => 'boolean|required',
+ 'isSpa' => 'boolean|required',
+ 'isBuildServerEnabled' => 'boolean|required',
+ 'isContainerLabelEscapeEnabled' => 'boolean|required',
+ 'isContainerLabelReadonlyEnabled' => 'boolean|required',
+ 'isPreserveRepositoryEnabled' => 'boolean|required',
+ 'isHttpBasicAuthEnabled' => 'boolean|required',
+ 'httpBasicAuthUsername' => 'string|nullable',
+ 'httpBasicAuthPassword' => 'string|nullable',
+ 'watchPaths' => 'nullable',
'redirect' => 'string|required',
];
}
@@ -193,26 +189,26 @@ protected function messages(): array
'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.',
+ 'gitRepository.required' => 'The Git Repository field is required.',
+ 'gitBranch.required' => 'The Git Branch field is required.',
+ 'buildPack.required' => 'The Build Pack field is required.',
+ 'staticImage.required' => 'The Static Image field is required.',
+ 'baseDirectory.required' => 'The Base Directory field is required.',
+ 'portsExposes.required' => 'The Exposed Ports field is required.',
+ 'isStatic.required' => 'The Static setting is required.',
+ 'isStatic.boolean' => 'The Static setting must be true or false.',
+ 'isSpa.required' => 'The SPA setting is required.',
+ 'isSpa.boolean' => 'The SPA setting must be true or false.',
+ 'isBuildServerEnabled.required' => 'The Build Server setting is required.',
+ 'isBuildServerEnabled.boolean' => 'The Build Server setting must be true or false.',
+ 'isContainerLabelEscapeEnabled.required' => 'The Container Label Escape setting is required.',
+ 'isContainerLabelEscapeEnabled.boolean' => 'The Container Label Escape setting must be true or false.',
+ 'isContainerLabelReadonlyEnabled.required' => 'The Container Label Readonly setting is required.',
+ 'isContainerLabelReadonlyEnabled.boolean' => 'The Container Label Readonly setting must be true or false.',
+ 'isPreserveRepositoryEnabled.required' => 'The Preserve Repository setting is required.',
+ 'isPreserveRepositoryEnabled.boolean' => 'The Preserve Repository setting must be true or false.',
+ 'isHttpBasicAuthEnabled.required' => 'The HTTP Basic Auth setting is required.',
+ 'isHttpBasicAuthEnabled.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.',
]
@@ -220,43 +216,43 @@ protected function messages(): array
}
protected $validationAttributes = [
- 'application.name' => 'name',
- 'application.description' => 'description',
- 'application.fqdn' => 'FQDN',
- 'application.git_repository' => 'Git repository',
- 'application.git_branch' => 'Git branch',
- 'application.git_commit_sha' => 'Git commit SHA',
- 'application.install_command' => 'Install command',
- 'application.build_command' => 'Build command',
- 'application.start_command' => 'Start command',
- 'application.build_pack' => 'Build pack',
- 'application.static_image' => 'Static image',
- 'application.base_directory' => 'Base directory',
- 'application.publish_directory' => 'Publish directory',
- 'application.ports_exposes' => 'Ports exposes',
- 'application.ports_mappings' => 'Ports mappings',
- 'application.dockerfile' => 'Dockerfile',
- 'application.docker_registry_image_name' => 'Docker registry image name',
- 'application.docker_registry_image_tag' => 'Docker registry image tag',
- 'application.dockerfile_location' => 'Dockerfile location',
- 'application.docker_compose_location' => 'Docker compose location',
- 'application.docker_compose' => 'Docker compose',
- 'application.docker_compose_raw' => 'Docker compose raw',
- 'application.custom_labels' => 'Custom labels',
- 'application.dockerfile_target_build' => 'Dockerfile target build',
- 'application.custom_docker_run_options' => 'Custom docker run commands',
- 'application.custom_network_aliases' => 'Custom docker network aliases',
- 'application.docker_compose_custom_start_command' => 'Docker compose custom start command',
- 'application.docker_compose_custom_build_command' => 'Docker compose custom build command',
- 'application.custom_nginx_configuration' => 'Custom Nginx configuration',
- 'application.settings.is_static' => 'Is static',
- 'application.settings.is_spa' => 'Is SPA',
- 'application.settings.is_build_server_enabled' => 'Is build server enabled',
- 'application.settings.is_container_label_escape_enabled' => 'Is container label escape enabled',
- 'application.settings.is_container_label_readonly_enabled' => 'Is container label readonly',
- 'application.settings.is_preserve_repository_enabled' => 'Is preserve repository enabled',
- 'application.watch_paths' => 'Watch paths',
- 'application.redirect' => 'Redirect',
+ 'name' => 'name',
+ 'description' => 'description',
+ 'fqdn' => 'FQDN',
+ 'gitRepository' => 'Git repository',
+ 'gitBranch' => 'Git branch',
+ 'gitCommitSha' => 'Git commit SHA',
+ 'installCommand' => 'Install command',
+ 'buildCommand' => 'Build command',
+ 'startCommand' => 'Start command',
+ 'buildPack' => 'Build pack',
+ 'staticImage' => 'Static image',
+ 'baseDirectory' => 'Base directory',
+ 'publishDirectory' => 'Publish directory',
+ 'portsExposes' => 'Ports exposes',
+ 'portsMappings' => 'Ports mappings',
+ 'dockerfile' => 'Dockerfile',
+ 'dockerRegistryImageName' => 'Docker registry image name',
+ 'dockerRegistryImageTag' => 'Docker registry image tag',
+ 'dockerfileLocation' => 'Dockerfile location',
+ 'dockerComposeLocation' => 'Docker compose location',
+ 'dockerCompose' => 'Docker compose',
+ 'dockerComposeRaw' => 'Docker compose raw',
+ 'customLabels' => 'Custom labels',
+ 'dockerfileTargetBuild' => 'Dockerfile target build',
+ 'customDockerRunOptions' => 'Custom docker run commands',
+ 'customNetworkAliases' => 'Custom docker network aliases',
+ 'dockerComposeCustomStartCommand' => 'Docker compose custom start command',
+ 'dockerComposeCustomBuildCommand' => 'Docker compose custom build command',
+ 'customNginxConfiguration' => 'Custom Nginx configuration',
+ 'isStatic' => 'Is static',
+ 'isSpa' => 'Is SPA',
+ 'isBuildServerEnabled' => 'Is build server enabled',
+ 'isContainerLabelEscapeEnabled' => 'Is container label escape enabled',
+ 'isContainerLabelReadonlyEnabled' => 'Is container label readonly',
+ 'isPreserveRepositoryEnabled' => 'Is preserve repository enabled',
+ 'watchPaths' => 'Watch paths',
+ 'redirect' => 'Redirect',
];
public function mount()
@@ -266,14 +262,14 @@ public function mount()
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();
+ $this->syncData();
return;
}
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
// Still sync data even on error, so form fields are populated
- $this->syncFromModel();
+ $this->syncData();
}
if ($this->application->build_pack === 'dockercompose') {
// Only update if user has permission
@@ -325,57 +321,112 @@ public function mount()
// 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();
+ $this->syncData();
}
- protected function getModelBindings(): array
+ public function syncData(bool $toModel = false): void
{
- 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',
- ];
+ if ($toModel) {
+ $this->validate();
+
+ // Application properties
+ $this->application->name = $this->name;
+ $this->application->description = $this->description;
+ $this->application->fqdn = $this->fqdn;
+ $this->application->git_repository = $this->gitRepository;
+ $this->application->git_branch = $this->gitBranch;
+ $this->application->git_commit_sha = $this->gitCommitSha;
+ $this->application->install_command = $this->installCommand;
+ $this->application->build_command = $this->buildCommand;
+ $this->application->start_command = $this->startCommand;
+ $this->application->build_pack = $this->buildPack;
+ $this->application->static_image = $this->staticImage;
+ $this->application->base_directory = $this->baseDirectory;
+ $this->application->publish_directory = $this->publishDirectory;
+ $this->application->ports_exposes = $this->portsExposes;
+ $this->application->ports_mappings = $this->portsMappings;
+ $this->application->custom_network_aliases = $this->customNetworkAliases;
+ $this->application->dockerfile = $this->dockerfile;
+ $this->application->dockerfile_location = $this->dockerfileLocation;
+ $this->application->dockerfile_target_build = $this->dockerfileTargetBuild;
+ $this->application->docker_registry_image_name = $this->dockerRegistryImageName;
+ $this->application->docker_registry_image_tag = $this->dockerRegistryImageTag;
+ $this->application->docker_compose_location = $this->dockerComposeLocation;
+ $this->application->docker_compose = $this->dockerCompose;
+ $this->application->docker_compose_raw = $this->dockerComposeRaw;
+ $this->application->docker_compose_custom_start_command = $this->dockerComposeCustomStartCommand;
+ $this->application->docker_compose_custom_build_command = $this->dockerComposeCustomBuildCommand;
+ $this->application->custom_labels = base64_encode($this->customLabels);
+ $this->application->custom_docker_run_options = $this->customDockerRunOptions;
+ $this->application->pre_deployment_command = $this->preDeploymentCommand;
+ $this->application->pre_deployment_command_container = $this->preDeploymentCommandContainer;
+ $this->application->post_deployment_command = $this->postDeploymentCommand;
+ $this->application->post_deployment_command_container = $this->postDeploymentCommandContainer;
+ $this->application->custom_nginx_configuration = $this->customNginxConfiguration;
+ $this->application->is_http_basic_auth_enabled = $this->isHttpBasicAuthEnabled;
+ $this->application->http_basic_auth_username = $this->httpBasicAuthUsername;
+ $this->application->http_basic_auth_password = $this->httpBasicAuthPassword;
+ $this->application->watch_paths = $this->watchPaths;
+ $this->application->redirect = $this->redirect;
+
+ // Application settings properties
+ $this->application->settings->is_static = $this->isStatic;
+ $this->application->settings->is_spa = $this->isSpa;
+ $this->application->settings->is_build_server_enabled = $this->isBuildServerEnabled;
+ $this->application->settings->is_preserve_repository_enabled = $this->isPreserveRepositoryEnabled;
+ $this->application->settings->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled;
+ $this->application->settings->is_container_label_readonly_enabled = $this->isContainerLabelReadonlyEnabled;
+
+ $this->application->settings->save();
+ } else {
+ // From model to properties
+ $this->name = $this->application->name;
+ $this->description = $this->application->description;
+ $this->fqdn = $this->application->fqdn;
+ $this->gitRepository = $this->application->git_repository;
+ $this->gitBranch = $this->application->git_branch;
+ $this->gitCommitSha = $this->application->git_commit_sha;
+ $this->installCommand = $this->application->install_command;
+ $this->buildCommand = $this->application->build_command;
+ $this->startCommand = $this->application->start_command;
+ $this->buildPack = $this->application->build_pack;
+ $this->staticImage = $this->application->static_image;
+ $this->baseDirectory = $this->application->base_directory;
+ $this->publishDirectory = $this->application->publish_directory;
+ $this->portsExposes = $this->application->ports_exposes;
+ $this->portsMappings = $this->application->ports_mappings;
+ $this->customNetworkAliases = $this->application->custom_network_aliases;
+ $this->dockerfile = $this->application->dockerfile;
+ $this->dockerfileLocation = $this->application->dockerfile_location;
+ $this->dockerfileTargetBuild = $this->application->dockerfile_target_build;
+ $this->dockerRegistryImageName = $this->application->docker_registry_image_name;
+ $this->dockerRegistryImageTag = $this->application->docker_registry_image_tag;
+ $this->dockerComposeLocation = $this->application->docker_compose_location;
+ $this->dockerCompose = $this->application->docker_compose;
+ $this->dockerComposeRaw = $this->application->docker_compose_raw;
+ $this->dockerComposeCustomStartCommand = $this->application->docker_compose_custom_start_command;
+ $this->dockerComposeCustomBuildCommand = $this->application->docker_compose_custom_build_command;
+ $this->customLabels = $this->application->parseContainerLabels();
+ $this->customDockerRunOptions = $this->application->custom_docker_run_options;
+ $this->preDeploymentCommand = $this->application->pre_deployment_command;
+ $this->preDeploymentCommandContainer = $this->application->pre_deployment_command_container;
+ $this->postDeploymentCommand = $this->application->post_deployment_command;
+ $this->postDeploymentCommandContainer = $this->application->post_deployment_command_container;
+ $this->customNginxConfiguration = $this->application->custom_nginx_configuration;
+ $this->isHttpBasicAuthEnabled = $this->application->is_http_basic_auth_enabled;
+ $this->httpBasicAuthUsername = $this->application->http_basic_auth_username;
+ $this->httpBasicAuthPassword = $this->application->http_basic_auth_password;
+ $this->watchPaths = $this->application->watch_paths;
+ $this->redirect = $this->application->redirect;
+
+ // Application settings properties
+ $this->isStatic = $this->application->settings->is_static;
+ $this->isSpa = $this->application->settings->is_spa;
+ $this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
+ $this->isPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
+ $this->isContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
+ $this->isContainerLabelReadonlyEnabled = $this->application->settings->is_container_label_readonly_enabled;
+ }
}
public function instantSave()
@@ -387,7 +438,7 @@ public function instantSave()
$oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
$oldIsPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
- $this->syncToModel();
+ $this->syncData(toModel: true);
if ($this->application->settings->isDirty('is_spa')) {
$this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static');
@@ -395,24 +446,27 @@ public function instantSave()
if ($this->application->isDirty('is_http_basic_auth_enabled')) {
$this->application->save();
}
+
$this->application->settings->save();
+
$this->dispatch('success', 'Settings saved.');
$this->application->refresh();
- $this->syncFromModel();
+
+ $this->syncData();
// If port_exposes changed, reset default labels
- if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
+ if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) {
$this->resetDefaultLabels(false);
}
- if ($oldIsPreserveRepositoryEnabled !== $this->is_preserve_repository_enabled) {
- if ($this->is_preserve_repository_enabled === false) {
+ if ($oldIsPreserveRepositoryEnabled !== $this->isPreserveRepositoryEnabled) {
+ if ($this->isPreserveRepositoryEnabled === false) {
$this->application->fileStorages->each(function ($storage) {
- $storage->is_based_on_git = $this->is_preserve_repository_enabled;
+ $storage->is_based_on_git = $this->isPreserveRepositoryEnabled;
$storage->save();
});
}
}
- if ($this->is_container_label_readonly_enabled) {
+ if ($this->isContainerLabelReadonlyEnabled) {
$this->resetDefaultLabels(false);
}
} catch (\Throwable $e) {
@@ -441,7 +495,7 @@ public function loadComposeFile($isInit = false, $showToast = true)
// Sync the docker_compose_raw from the model to the component property
// This ensures the Monaco editor displays the loaded compose file
- $this->syncFromModel();
+ $this->syncData();
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots and dashes to use underscores for HTML form binding
@@ -507,7 +561,7 @@ public function generateDomain(string $serviceName)
public function updatedBaseDirectory()
{
- if ($this->build_pack === 'dockercompose') {
+ if ($this->buildPack === 'dockercompose') {
$this->loadComposeFile();
}
}
@@ -527,24 +581,24 @@ public function updatedBuildPack()
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User doesn't have permission, revert the change and return
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
return;
}
// Sync property to model before checking/modifying
- $this->syncToModel();
+ $this->syncData(toModel: true);
- if ($this->build_pack !== 'nixpacks') {
- $this->is_static = false;
+ if ($this->buildPack !== 'nixpacks') {
+ $this->isStatic = false;
$this->application->settings->is_static = false;
$this->application->settings->save();
} else {
- $this->ports_exposes = 3000;
- $this->application->ports_exposes = 3000;
+ $this->portsExposes = '3000';
+ $this->application->ports_exposes = '3000';
$this->resetDefaultLabels(false);
}
- if ($this->build_pack === 'dockercompose') {
+ if ($this->buildPack === 'dockercompose') {
// Only update if user has permission
try {
$this->authorize('update', $this->application);
@@ -567,9 +621,9 @@ public function updatedBuildPack()
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
}
}
- if ($this->build_pack === 'static') {
- $this->ports_exposes = 80;
- $this->application->ports_exposes = 80;
+ if ($this->buildPack === 'static') {
+ $this->portsExposes = '80';
+ $this->application->ports_exposes = '80';
$this->resetDefaultLabels(false);
$this->generateNginxConfiguration();
}
@@ -586,10 +640,10 @@ public function getWildcardDomain()
if ($server) {
$fqdn = generateUrl(server: $server, random: $this->application->uuid);
$this->fqdn = $fqdn;
- $this->syncToModel();
+ $this->syncData(toModel: true);
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
$this->resetDefaultLabels();
$this->dispatch('success', 'Wildcard domain generated.');
}
@@ -603,11 +657,11 @@ public function generateNginxConfiguration($type = 'static')
try {
$this->authorize('update', $this->application);
- $this->custom_nginx_configuration = defaultNginxConfiguration($type);
- $this->syncToModel();
+ $this->customNginxConfiguration = defaultNginxConfiguration($type);
+ $this->syncData(toModel: true);
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
$this->dispatch('success', 'Nginx configuration generated.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -617,16 +671,15 @@ public function generateNginxConfiguration($type = 'static')
public function resetDefaultLabels($manualReset = false)
{
try {
- if (! $this->is_container_label_readonly_enabled && ! $manualReset) {
+ if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) {
return;
}
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
- $this->custom_labels = base64_encode($this->customLabels);
- $this->syncToModel();
+ $this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
- if ($this->build_pack === 'dockercompose') {
+ $this->syncData();
+ if ($this->buildPack === 'dockercompose') {
$this->loadComposeFile(showToast: false);
}
$this->dispatch('configurationChanged');
@@ -722,7 +775,7 @@ public function submit($showToaster = true)
$this->dispatch('warning', __('warning.sslipdomain'));
}
- $this->syncToModel();
+ $this->syncData(toModel: true);
if ($this->application->isDirty('redirect')) {
$this->setRedirect();
@@ -742,42 +795,42 @@ public function submit($showToaster = true)
$this->application->save();
}
- if ($this->build_pack === 'dockercompose' && $oldDockerComposeLocation !== $this->docker_compose_location) {
+ if ($this->buildPack === 'dockercompose' && $oldDockerComposeLocation !== $this->dockerComposeLocation) {
$compose_return = $this->loadComposeFile(showToast: false);
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
return;
}
}
- if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
+ if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) {
$this->resetDefaultLabels();
}
- if ($this->build_pack === 'dockerimage') {
+ if ($this->buildPack === 'dockerimage') {
$this->validate([
- 'docker_registry_image_name' => 'required',
+ 'dockerRegistryImageName' => 'required',
]);
}
- 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 ($this->customDockerRunOptions) {
+ $this->customDockerRunOptions = str($this->customDockerRunOptions)->trim()->toString();
+ $this->application->custom_docker_run_options = $this->customDockerRunOptions;
}
if ($this->dockerfile) {
$port = get_port_from_dockerfile($this->dockerfile);
- if ($port && ! $this->ports_exposes) {
- $this->ports_exposes = $port;
+ if ($port && ! $this->portsExposes) {
+ $this->portsExposes = $port;
$this->application->ports_exposes = $port;
}
}
- if ($this->base_directory && $this->base_directory !== '/') {
- $this->base_directory = rtrim($this->base_directory, '/');
- $this->application->base_directory = $this->base_directory;
+ if ($this->baseDirectory && $this->baseDirectory !== '/') {
+ $this->baseDirectory = rtrim($this->baseDirectory, '/');
+ $this->application->base_directory = $this->baseDirectory;
}
- if ($this->publish_directory && $this->publish_directory !== '/') {
- $this->publish_directory = rtrim($this->publish_directory, '/');
- $this->application->publish_directory = $this->publish_directory;
+ if ($this->publishDirectory && $this->publishDirectory !== '/') {
+ $this->publishDirectory = rtrim($this->publishDirectory, '/');
+ $this->application->publish_directory = $this->publishDirectory;
}
- if ($this->build_pack === 'dockercompose') {
+ if ($this->buildPack === 'dockercompose') {
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
@@ -809,11 +862,11 @@ public function submit($showToaster = true)
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
$showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!');
} catch (\Throwable $e) {
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
return handleError($e, $this);
} finally {
diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php
index 4b03c69e1..26cb937b3 100644
--- a/app/Models/ApplicationSetting.php
+++ b/app/Models/ApplicationSetting.php
@@ -7,8 +7,14 @@
class ApplicationSetting extends Model
{
- protected $cast = [
+ protected $casts = [
'is_static' => 'boolean',
+ 'is_spa' => 'boolean',
+ 'is_build_server_enabled' => 'boolean',
+ 'is_preserve_repository_enabled' => 'boolean',
+ 'is_container_label_escape_enabled' => 'boolean',
+ 'is_container_label_readonly_enabled' => 'boolean',
+ 'use_build_secrets' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_debug_enabled' => 'boolean',
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php
index 7759e0604..2484005ef 100644
--- a/resources/views/livewire/project/application/general.blade.php
+++ b/resources/views/livewire/project/application/general.blade.php
@@ -23,7 +23,7 @@
@if (!$application->dockerfile && $application->build_pack !== 'dockerimage')
-
@@ -31,7 +31,7 @@
@if ($application->settings->is_static || $application->build_pack === 'static')
-
@@ -66,7 +66,7 @@
@endif
@if ($application->settings->is_static || $application->build_pack === 'static')
-
@can('update', $application)
@@ -77,13 +77,13 @@
@endif
@if ($application->could_set_build_commands())
-
@endif
@if ($application->settings->is_static && $application->build_pack !== 'static')
@endif
@@ -164,15 +164,15 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->build_pack === 'dockerimage')
@if ($application->destination->server->isSwarm())
-
-
@else
-
-
@endif
@@ -181,18 +181,18 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
$application->destination->server->isSwarm() ||
$application->additional_servers->count() > 0 ||
$application->settings->is_build_server_enabled)
-
-
@else
-
-
@@ -206,20 +206,20 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@else
@if ($application->could_set_build_commands())
@if ($application->build_pack === 'nixpacks')
Nixpacks will detect the required configuration
@@ -239,16 +239,16 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endcan
@@ -261,12 +261,12 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@@ -274,36 +274,36 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endif
@else
-
@if ($application->build_pack === 'dockerfile' && !$application->dockerfile)
-
@endif
@if ($application->build_pack === 'dockerfile')
-
@endif
@if ($application->could_set_build_commands())
@if ($application->settings->is_static)
-
@else
-
@endif
@endif
@@ -313,21 +313,21 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endif
@if ($application->build_pack !== 'dockercompose')
@endif
@@ -344,18 +344,18 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endcan
@if ($application->settings->is_raw_compose_deployment_enabled)
-
@else
@if ((int) $application->compose_parsing_version >= 3)
-
@endif
-
@@ -363,11 +363,11 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
{{-- --}}
+ id="isContainerLabelReadonlyEnabled" instantSave> --}}
@endif
@if ($application->dockerfile)
@@ -378,30 +378,30 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
Network
@if ($application->settings->is_static || $application->build_pack === 'static')
-
@else
@if ($application->settings->is_container_label_readonly_enabled === false)
-
@else
-
@endif
@endif
@if (!$application->destination->server->isSwarm())
-
@endif
@if (!$application->destination->server->isSwarm())
-
+ wire:model="customNetworkAliases" x-bind:disabled="!canUpdate" />
@endif
@@ -409,14 +409,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->is_http_basic_auth_enabled)
-
-
@endif
@@ -432,11 +432,11 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@can('update', $application)
@@ -455,21 +455,21 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
Pre/Post Deployment Commands
@if ($application->build_pack === 'dockercompose')
-
@endif
@if ($application->build_pack === 'dockercompose')
@endif
diff --git a/tests/Unit/ApplicationSettingStaticCastTest.php b/tests/Unit/ApplicationSettingStaticCastTest.php
new file mode 100644
index 000000000..35ab7faaf
--- /dev/null
+++ b/tests/Unit/ApplicationSettingStaticCastTest.php
@@ -0,0 +1,105 @@
+is_static = true;
+
+ // Verify it's cast to boolean
+ expect($setting->is_static)->toBeTrue()
+ ->and($setting->is_static)->toBeBool();
+});
+
+it('casts is_static to boolean when false', function () {
+ $setting = new ApplicationSetting;
+ $setting->is_static = false;
+
+ // Verify it's cast to boolean
+ expect($setting->is_static)->toBeFalse()
+ ->and($setting->is_static)->toBeBool();
+});
+
+it('casts is_static from string "1" to boolean true', function () {
+ $setting = new ApplicationSetting;
+ $setting->is_static = '1';
+
+ // Should cast string to boolean
+ expect($setting->is_static)->toBeTrue()
+ ->and($setting->is_static)->toBeBool();
+});
+
+it('casts is_static from string "0" to boolean false', function () {
+ $setting = new ApplicationSetting;
+ $setting->is_static = '0';
+
+ // Should cast string to boolean
+ expect($setting->is_static)->toBeFalse()
+ ->and($setting->is_static)->toBeBool();
+});
+
+it('casts is_static from integer 1 to boolean true', function () {
+ $setting = new ApplicationSetting;
+ $setting->is_static = 1;
+
+ // Should cast integer to boolean
+ expect($setting->is_static)->toBeTrue()
+ ->and($setting->is_static)->toBeBool();
+});
+
+it('casts is_static from integer 0 to boolean false', function () {
+ $setting = new ApplicationSetting;
+ $setting->is_static = 0;
+
+ // Should cast integer to boolean
+ expect($setting->is_static)->toBeFalse()
+ ->and($setting->is_static)->toBeBool();
+});
+
+it('has casts array property defined correctly', function () {
+ $setting = new ApplicationSetting;
+
+ // Verify the casts property exists and is configured
+ $casts = $setting->getCasts();
+
+ expect($casts)->toHaveKey('is_static')
+ ->and($casts['is_static'])->toBe('boolean');
+});
+
+it('casts all boolean fields correctly', function () {
+ $setting = new ApplicationSetting;
+
+ // Get all casts
+ $casts = $setting->getCasts();
+
+ // Verify all expected boolean fields are cast
+ $expectedBooleanCasts = [
+ 'is_static',
+ 'is_spa',
+ 'is_build_server_enabled',
+ 'is_preserve_repository_enabled',
+ 'is_container_label_escape_enabled',
+ 'is_container_label_readonly_enabled',
+ 'use_build_secrets',
+ 'is_auto_deploy_enabled',
+ 'is_force_https_enabled',
+ 'is_debug_enabled',
+ 'is_preview_deployments_enabled',
+ 'is_pr_deployments_public_enabled',
+ 'is_git_submodules_enabled',
+ 'is_git_lfs_enabled',
+ 'is_git_shallow_clone_enabled',
+ ];
+
+ foreach ($expectedBooleanCasts as $field) {
+ expect($casts)->toHaveKey($field)
+ ->and($casts[$field])->toBe('boolean');
+ }
+});
diff --git a/tests/Unit/SynchronizesModelDataTest.php b/tests/Unit/SynchronizesModelDataTest.php
new file mode 100644
index 000000000..4551fb056
--- /dev/null
+++ b/tests/Unit/SynchronizesModelDataTest.php
@@ -0,0 +1,163 @@
+ 'application.settings.is_static',
+ ];
+ }
+
+ // Expose protected method for testing
+ public function testSync(): void
+ {
+ $this->syncToModel();
+ }
+ };
+
+ // Create real ApplicationSetting instance
+ $settings = new ApplicationSetting;
+ $settings->is_static = false;
+
+ // Create Application instance
+ $application = new Application;
+ $application->setRelation('settings', $settings);
+
+ $component->application = $application;
+ $component->is_static = true;
+
+ // Sync to model
+ $component->testSync();
+
+ // Verify the value was set on the model
+ expect($component->application->settings->is_static)->toBeTrue();
+});
+
+it('syncs boolean values correctly', function () {
+ $component = new class
+ {
+ use SynchronizesModelData;
+
+ public bool $is_spa = true;
+
+ public bool $is_build_server_enabled = false;
+
+ public Application $application;
+
+ protected function getModelBindings(): array
+ {
+ return [
+ 'is_spa' => 'application.settings.is_spa',
+ 'is_build_server_enabled' => 'application.settings.is_build_server_enabled',
+ ];
+ }
+
+ public function testSync(): void
+ {
+ $this->syncToModel();
+ }
+ };
+
+ $settings = new ApplicationSetting;
+ $settings->is_spa = false;
+ $settings->is_build_server_enabled = true;
+
+ $application = new Application;
+ $application->setRelation('settings', $settings);
+
+ $component->application = $application;
+
+ $component->testSync();
+
+ expect($component->application->settings->is_spa)->toBeTrue()
+ ->and($component->application->settings->is_build_server_enabled)->toBeFalse();
+});
+
+it('syncs from model to component correctly', function () {
+ $component = new class
+ {
+ use SynchronizesModelData;
+
+ public bool $is_static = false;
+
+ public bool $is_spa = false;
+
+ public Application $application;
+
+ protected function getModelBindings(): array
+ {
+ return [
+ 'is_static' => 'application.settings.is_static',
+ 'is_spa' => 'application.settings.is_spa',
+ ];
+ }
+
+ public function testSyncFrom(): void
+ {
+ $this->syncFromModel();
+ }
+ };
+
+ $settings = new ApplicationSetting;
+ $settings->is_static = true;
+ $settings->is_spa = true;
+
+ $application = new Application;
+ $application->setRelation('settings', $settings);
+
+ $component->application = $application;
+
+ $component->testSyncFrom();
+
+ expect($component->is_static)->toBeTrue()
+ ->and($component->is_spa)->toBeTrue();
+});
+
+it('handles properties that do not exist gracefully', function () {
+ $component = new class
+ {
+ use SynchronizesModelData;
+
+ public Application $application;
+
+ protected function getModelBindings(): array
+ {
+ return [
+ 'non_existent_property' => 'application.name',
+ ];
+ }
+
+ public function testSync(): void
+ {
+ $this->syncToModel();
+ }
+ };
+
+ $application = new Application;
+ $component->application = $application;
+
+ // Should not throw an error
+ $component->testSync();
+
+ expect(true)->toBeTrue();
+});
From 3d9c4954c141caf14c5223fad019c165fb99156a Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 4 Nov 2025 08:51:05 +0100
Subject: [PATCH 2/3] feat: Enhance General component with additional
properties and validation rules
---
app/Livewire/Project/Application/General.php | 45 ++++++++++++++++++++
1 file changed, 45 insertions(+)
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 03db8b1c8..7e606459b 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -7,6 +7,7 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
+use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@@ -21,92 +22,136 @@ class General extends Component
public Collection $services;
+ #[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')]
public string $name;
+ #[Validate(['string', 'nullable'])]
public ?string $description = null;
+ #[Validate(['nullable'])]
public ?string $fqdn = null;
+ #[Validate(['required'])]
public string $gitRepository;
+ #[Validate(['required'])]
public string $gitBranch;
+ #[Validate(['string', 'nullable'])]
public ?string $gitCommitSha = null;
+ #[Validate(['string', 'nullable'])]
public ?string $installCommand = null;
+ #[Validate(['string', 'nullable'])]
public ?string $buildCommand = null;
+ #[Validate(['string', 'nullable'])]
public ?string $startCommand = null;
+ #[Validate(['required'])]
public string $buildPack;
+ #[Validate(['required'])]
public string $staticImage;
+ #[Validate(['required'])]
public string $baseDirectory;
+ #[Validate(['string', 'nullable'])]
public ?string $publishDirectory = null;
+ #[Validate(['string', 'nullable'])]
public ?string $portsExposes = null;
+ #[Validate(['string', 'nullable'])]
public ?string $portsMappings = null;
+ #[Validate(['string', 'nullable'])]
public ?string $customNetworkAliases = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerfileLocation = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerfileTargetBuild = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageName = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerComposeLocation = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerCompose = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerComposeRaw = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomStartCommand = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomBuildCommand = null;
+ #[Validate(['string', 'nullable'])]
public ?string $customDockerRunOptions = null;
+ #[Validate(['string', 'nullable'])]
public ?string $preDeploymentCommand = null;
+ #[Validate(['string', 'nullable'])]
public ?string $preDeploymentCommandContainer = null;
+ #[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommand = null;
+ #[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommandContainer = null;
+ #[Validate(['string', 'nullable'])]
public ?string $customNginxConfiguration = null;
+ #[Validate(['boolean', 'required'])]
public bool $isStatic = false;
+ #[Validate(['boolean', 'required'])]
public bool $isSpa = false;
+ #[Validate(['boolean', 'required'])]
public bool $isBuildServerEnabled = false;
+ #[Validate(['boolean', 'required'])]
public bool $isPreserveRepositoryEnabled = false;
+ #[Validate(['boolean', 'required'])]
public bool $isContainerLabelEscapeEnabled = true;
+ #[Validate(['boolean', 'required'])]
public bool $isContainerLabelReadonlyEnabled = false;
+ #[Validate(['boolean', 'required'])]
public bool $isHttpBasicAuthEnabled = false;
+ #[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthUsername = null;
+ #[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthPassword = null;
+ #[Validate(['nullable'])]
public ?string $watchPaths = null;
+ #[Validate(['string', 'required'])]
public string $redirect;
+ #[Validate(['nullable'])]
public $customLabels;
public bool $labelsChanged = false;
From faa62dec57129b9c0df2afa192eadea9f9ae22ef Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 4 Nov 2025 09:18:05 +0100
Subject: [PATCH 3/3] refactor: Remove SynchronizesModelData trait and
implement syncData method for model synchronization
---
.cursor/rules/frontend-patterns.mdc | 351 +++++++++++++++++-
.../Concerns/SynchronizesModelData.php | 35 --
app/Livewire/Project/Service/EditDomain.php | 35 +-
app/Livewire/Project/Service/FileStorage.php | 35 +-
.../Service/ServiceApplicationView.php | 75 +++-
app/Livewire/Project/Shared/HealthChecks.php | 121 ++++--
tests/Unit/SynchronizesModelDataTest.php | 163 --------
7 files changed, 548 insertions(+), 267 deletions(-)
delete mode 100644 app/Livewire/Concerns/SynchronizesModelData.php
delete mode 100644 tests/Unit/SynchronizesModelDataTest.php
diff --git a/.cursor/rules/frontend-patterns.mdc b/.cursor/rules/frontend-patterns.mdc
index 663490d3b..4730160b2 100644
--- a/.cursor/rules/frontend-patterns.mdc
+++ b/.cursor/rules/frontend-patterns.mdc
@@ -267,18 +267,365 @@ For complete documentation, see **[form-components.mdc](mdc:.cursor/rules/form-c
## Form Handling Patterns
+### Livewire Component Data Synchronization Pattern
+
+**IMPORTANT**: All Livewire components must use the **manual `syncData()` pattern** for synchronizing component properties with Eloquent models.
+
+#### Property Naming Convention
+- **Component properties**: Use camelCase (e.g., `$gitRepository`, `$isStatic`)
+- **Database columns**: Use snake_case (e.g., `git_repository`, `is_static`)
+- **View bindings**: Use camelCase matching component properties (e.g., `id="gitRepository"`)
+
+#### The syncData() Method Pattern
+
+```php
+use Livewire\Attributes\Validate;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+
+class MyComponent extends Component
+{
+ use AuthorizesRequests;
+
+ public Application $application;
+
+ // Properties with validation attributes
+ #[Validate(['required'])]
+ public string $name;
+
+ #[Validate(['string', 'nullable'])]
+ public ?string $description = null;
+
+ #[Validate(['boolean', 'required'])]
+ public bool $isStatic = false;
+
+ public function mount()
+ {
+ $this->authorize('view', $this->application);
+ $this->syncData(); // Load from model
+ }
+
+ public function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->validate();
+
+ // Sync TO model (camelCase → snake_case)
+ $this->application->name = $this->name;
+ $this->application->description = $this->description;
+ $this->application->is_static = $this->isStatic;
+
+ $this->application->save();
+ } else {
+ // Sync FROM model (snake_case → camelCase)
+ $this->name = $this->application->name;
+ $this->description = $this->application->description;
+ $this->isStatic = $this->application->is_static;
+ }
+ }
+
+ public function submit()
+ {
+ $this->authorize('update', $this->application);
+ $this->syncData(toModel: true); // Save to model
+ $this->dispatch('success', 'Saved successfully.');
+ }
+}
+```
+
+#### Validation with #[Validate] Attributes
+
+All component properties should have `#[Validate]` attributes:
+
+```php
+// Boolean properties
+#[Validate(['boolean'])]
+public bool $isEnabled = false;
+
+// Required strings
+#[Validate(['string', 'required'])]
+public string $name;
+
+// Nullable strings
+#[Validate(['string', 'nullable'])]
+public ?string $description = null;
+
+// With constraints
+#[Validate(['integer', 'min:1'])]
+public int $timeout;
+```
+
+#### Benefits of syncData() Pattern
+
+- **Explicit Control**: Clear visibility of what's being synchronized
+- **Type Safety**: #[Validate] attributes provide compile-time validation info
+- **Easy Debugging**: Single method to check for data flow issues
+- **Maintainability**: All sync logic in one place
+- **Flexibility**: Can add custom logic (encoding, transformations, etc.)
+
+#### Creating New Form Components with syncData()
+
+#### Step-by-Step Component Creation Guide
+
+**Step 1: Define properties in camelCase with #[Validate] attributes**
+```php
+use Livewire\Attributes\Validate;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Component;
+
+class MyFormComponent extends Component
+{
+ use AuthorizesRequests;
+
+ // The model we're syncing with
+ public Application $application;
+
+ // Component properties in camelCase with validation
+ #[Validate(['string', 'required'])]
+ public string $name;
+
+ #[Validate(['string', 'nullable'])]
+ public ?string $gitRepository = null;
+
+ #[Validate(['string', 'nullable'])]
+ public ?string $installCommand = null;
+
+ #[Validate(['boolean'])]
+ public bool $isStatic = false;
+}
+```
+
+**Step 2: Implement syncData() method**
+```php
+public function syncData(bool $toModel = false): void
+{
+ if ($toModel) {
+ $this->validate();
+
+ // Sync TO model (component camelCase → database snake_case)
+ $this->application->name = $this->name;
+ $this->application->git_repository = $this->gitRepository;
+ $this->application->install_command = $this->installCommand;
+ $this->application->is_static = $this->isStatic;
+
+ $this->application->save();
+ } else {
+ // Sync FROM model (database snake_case → component camelCase)
+ $this->name = $this->application->name;
+ $this->gitRepository = $this->application->git_repository;
+ $this->installCommand = $this->application->install_command;
+ $this->isStatic = $this->application->is_static;
+ }
+}
+```
+
+**Step 3: Implement mount() to load initial data**
+```php
+public function mount()
+{
+ $this->authorize('view', $this->application);
+ $this->syncData(); // Load data from model to component properties
+}
+```
+
+**Step 4: Implement action methods with authorization**
+```php
+public function instantSave()
+{
+ try {
+ $this->authorize('update', $this->application);
+ $this->syncData(toModel: true); // Save component properties to model
+ $this->dispatch('success', 'Settings saved.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+}
+
+public function submit()
+{
+ try {
+ $this->authorize('update', $this->application);
+ $this->syncData(toModel: true); // Save component properties to model
+ $this->dispatch('success', 'Changes saved successfully.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+}
+```
+
+**Step 5: Create Blade view with camelCase bindings**
+```blade
+
+
+
+```
+
+**Key Points**:
+- Use `wire:model="camelCase"` and `id="camelCase"` in Blade views
+- Component properties are camelCase, database columns are snake_case
+- Always include authorization checks (`authorize()`, `canGate`, `canResource`)
+- Use `instantSave` for checkboxes that save immediately without form submission
+
+#### Special Patterns
+
+**Pattern 1: Related Models (e.g., Application → Settings)**
+```php
+public function syncData(bool $toModel = false): void
+{
+ if ($toModel) {
+ $this->validate();
+
+ // Sync main model
+ $this->application->name = $this->name;
+ $this->application->save();
+
+ // Sync related model
+ $this->application->settings->is_static = $this->isStatic;
+ $this->application->settings->save();
+ } else {
+ // From main model
+ $this->name = $this->application->name;
+
+ // From related model
+ $this->isStatic = $this->application->settings->is_static;
+ }
+}
+```
+
+**Pattern 2: Custom Encoding/Decoding**
+```php
+public function syncData(bool $toModel = false): void
+{
+ if ($toModel) {
+ $this->validate();
+
+ // Encode before saving
+ $this->application->custom_labels = base64_encode($this->customLabels);
+ $this->application->save();
+ } else {
+ // Decode when loading
+ $this->customLabels = $this->application->parseContainerLabels();
+ }
+}
+```
+
+**Pattern 3: Error Rollback**
+```php
+public function submit()
+{
+ $this->authorize('update', $this->resource);
+ $original = $this->model->getOriginal();
+
+ try {
+ $this->syncData(toModel: true);
+ $this->dispatch('success', 'Saved successfully.');
+ } catch (\Throwable $e) {
+ // Rollback on error
+ $this->model->setRawAttributes($original);
+ $this->model->save();
+ $this->syncData(); // Reload from model
+ return handleError($e, $this);
+ }
+}
+```
+
+#### Property Type Patterns
+
+**Required Strings**
+```php
+#[Validate(['string', 'required'])]
+public string $name; // No ?, no default, always has value
+```
+
+**Nullable Strings**
+```php
+#[Validate(['string', 'nullable'])]
+public ?string $description = null; // ?, = null, can be empty
+```
+
+**Booleans**
+```php
+#[Validate(['boolean'])]
+public bool $isEnabled = false; // Always has default value
+```
+
+**Integers with Constraints**
+```php
+#[Validate(['integer', 'min:1'])]
+public int $timeout; // Required
+
+#[Validate(['integer', 'min:1', 'nullable'])]
+public ?int $port = null; // Nullable
+```
+
+#### Testing Checklist
+
+After creating a new component with syncData(), verify:
+
+- [ ] All checkboxes save correctly (especially `instantSave` ones)
+- [ ] All form inputs persist to database
+- [ ] Custom encoded fields (like labels) display correctly if applicable
+- [ ] Form validation works for all fields
+- [ ] No console errors in browser
+- [ ] Authorization checks work (`@can` directives and `authorize()` calls)
+- [ ] Error rollback works if exceptions occur
+- [ ] Related models save correctly if applicable (e.g., Application + ApplicationSetting)
+
+#### Common Pitfalls to Avoid
+
+1. **snake_case in component properties**: Always use camelCase for component properties (e.g., `$gitRepository` not `$git_repository`)
+2. **Missing #[Validate] attributes**: Every property should have validation attributes for type safety
+3. **Forgetting to call syncData()**: Must call `syncData()` in `mount()` to load initial data
+4. **Missing authorization**: Always use `authorize()` in methods and `canGate`/`canResource` in views
+5. **View binding mismatch**: Use camelCase in Blade (e.g., `id="gitRepository"` not `id="git_repository"`)
+6. **wire:model vs wire:model.live**: Use `.live` for `instantSave` checkboxes to avoid timing issues
+7. **Validation sync**: If using `rules()` method, keep it in sync with `#[Validate]` attributes
+8. **Related models**: Don't forget to save both main and related models in syncData() method
+
### Livewire Forms
```php
class ServerCreateForm extends Component
{
public $name;
public $ip;
-
+
protected $rules = [
'name' => 'required|min:3',
'ip' => 'required|ip',
];
-
+
public function save()
{
$this->validate();
diff --git a/app/Livewire/Concerns/SynchronizesModelData.php b/app/Livewire/Concerns/SynchronizesModelData.php
deleted file mode 100644
index f8218c715..000000000
--- a/app/Livewire/Concerns/SynchronizesModelData.php
+++ /dev/null
@@ -1,35 +0,0 @@
- 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/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php
index f759dd71e..371c860ca 100644
--- a/app/Livewire/Project/Service/EditDomain.php
+++ b/app/Livewire/Project/Service/EditDomain.php
@@ -2,14 +2,16 @@
namespace App\Livewire\Project\Service;
-use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\ServiceApplication;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component
{
- use SynchronizesModelData;
+ use AuthorizesRequests;
+
public $applicationId;
public ServiceApplication $application;
@@ -20,6 +22,7 @@ class EditDomain extends Component
public $forceSaveDomains = false;
+ #[Validate(['nullable'])]
public ?string $fqdn = null;
protected $rules = [
@@ -28,16 +31,24 @@ class EditDomain extends Component
public function mount()
{
- $this->application = ServiceApplication::query()->findOrFail($this->applicationId);
+ $this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
$this->authorize('view', $this->application);
- $this->syncFromModel();
+ $this->syncData();
}
- protected function getModelBindings(): array
+ public function syncData(bool $toModel = false): void
{
- return [
- 'fqdn' => 'application.fqdn',
- ];
+ if ($toModel) {
+ $this->validate();
+
+ // Sync to model
+ $this->application->fqdn = $this->fqdn;
+
+ $this->application->save();
+ } else {
+ // Sync from model
+ $this->fqdn = $this->application->fqdn;
+ }
}
public function confirmDomainUsage()
@@ -64,8 +75,8 @@ public function submit()
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
- // Sync to model for domain conflict check
- $this->syncToModel();
+ // Sync to model for domain conflict check (without validation)
+ $this->application->fqdn = $this->fqdn;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@@ -83,7 +94,7 @@ public function submit()
$this->validate();
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
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.');
@@ -96,7 +107,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
- $this->syncFromModel();
+ $this->syncData();
}
return handleError($e, $this);
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 40539b13e..2ce4374a0 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -2,7 +2,6 @@
namespace App\Livewire\Project\Service;
-use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
@@ -19,11 +18,12 @@
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
+use Livewire\Attributes\Validate;
use Livewire\Component;
class FileStorage extends Component
{
- use AuthorizesRequests, SynchronizesModelData;
+ use AuthorizesRequests;
public LocalFileVolume $fileStorage;
@@ -37,8 +37,10 @@ class FileStorage extends Component
public bool $isReadOnly = false;
+ #[Validate(['nullable'])]
public ?string $content = null;
+ #[Validate(['required', 'boolean'])]
public bool $isBasedOnGit = false;
protected $rules = [
@@ -61,15 +63,24 @@ public function mount()
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
- $this->syncFromModel();
+ $this->syncData();
}
- protected function getModelBindings(): array
+ public function syncData(bool $toModel = false): void
{
- return [
- 'content' => 'fileStorage.content',
- 'isBasedOnGit' => 'fileStorage.is_based_on_git',
- ];
+ if ($toModel) {
+ $this->validate();
+
+ // Sync to model
+ $this->fileStorage->content = $this->content;
+ $this->fileStorage->is_based_on_git = $this->isBasedOnGit;
+
+ $this->fileStorage->save();
+ } else {
+ // Sync from model
+ $this->content = $this->fileStorage->content;
+ $this->isBasedOnGit = $this->fileStorage->is_based_on_git;
+ }
}
public function convertToDirectory()
@@ -96,7 +107,7 @@ public function loadStorageOnServer()
$this->authorize('update', $this->resource);
$this->fileStorage->loadStorageOnServer();
- $this->syncFromModel();
+ $this->syncData();
$this->dispatch('success', 'File storage loaded from server.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -165,14 +176,16 @@ public function submit()
if ($this->fileStorage->is_directory) {
$this->content = null;
}
- $this->syncToModel();
+ // Sync component properties to model
+ $this->fileStorage->content = $this->content;
+ $this->fileStorage->is_based_on_git = $this->isBasedOnGit;
$this->fileStorage->save();
$this->fileStorage->saveStorageOnServer();
$this->dispatch('success', 'File updated.');
} catch (\Throwable $e) {
$this->fileStorage->setRawAttributes($original);
$this->fileStorage->save();
- $this->syncFromModel();
+ $this->syncData();
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php
index 20358218f..2a661c4cf 100644
--- a/app/Livewire/Project/Service/ServiceApplicationView.php
+++ b/app/Livewire/Project/Service/ServiceApplicationView.php
@@ -2,20 +2,19 @@
namespace App\Livewire\Project\Service;
-use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\InstanceSettings;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
+use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class ServiceApplicationView extends Component
{
use AuthorizesRequests;
- use SynchronizesModelData;
public ServiceApplication $application;
@@ -31,20 +30,28 @@ class ServiceApplicationView extends Component
public $forceSaveDomains = false;
+ #[Validate(['nullable'])]
public ?string $humanName = null;
+ #[Validate(['nullable'])]
public ?string $description = null;
+ #[Validate(['nullable'])]
public ?string $fqdn = null;
+ #[Validate(['string', 'nullable'])]
public ?string $image = null;
+ #[Validate(['required', 'boolean'])]
public bool $excludeFromStatus = false;
+ #[Validate(['nullable', 'boolean'])]
public bool $isLogDrainEnabled = false;
+ #[Validate(['nullable', 'boolean'])]
public bool $isGzipEnabled = false;
+ #[Validate(['nullable', 'boolean'])]
public bool $isStripprefixEnabled = false;
protected $rules = [
@@ -79,7 +86,15 @@ public function instantSaveAdvanced()
return;
}
- $this->syncToModel();
+ // Sync component properties to model
+ $this->application->human_name = $this->humanName;
+ $this->application->description = $this->description;
+ $this->application->fqdn = $this->fqdn;
+ $this->application->image = $this->image;
+ $this->application->exclude_from_status = $this->excludeFromStatus;
+ $this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
+ $this->application->is_gzip_enabled = $this->isGzipEnabled;
+ $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
@@ -114,24 +129,39 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
- $this->syncFromModel();
+ $this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
- protected function getModelBindings(): array
+ public function syncData(bool $toModel = false): void
{
- 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',
- ];
+ if ($toModel) {
+ $this->validate();
+
+ // Sync to model
+ $this->application->human_name = $this->humanName;
+ $this->application->description = $this->description;
+ $this->application->fqdn = $this->fqdn;
+ $this->application->image = $this->image;
+ $this->application->exclude_from_status = $this->excludeFromStatus;
+ $this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
+ $this->application->is_gzip_enabled = $this->isGzipEnabled;
+ $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
+
+ $this->application->save();
+ } else {
+ // Sync from model
+ $this->humanName = $this->application->human_name;
+ $this->description = $this->application->description;
+ $this->fqdn = $this->application->fqdn;
+ $this->image = $this->application->image;
+ $this->excludeFromStatus = $this->application->exclude_from_status;
+ $this->isLogDrainEnabled = $this->application->is_log_drain_enabled;
+ $this->isGzipEnabled = $this->application->is_gzip_enabled;
+ $this->isStripprefixEnabled = $this->application->is_stripprefix_enabled;
+ }
}
public function convertToDatabase()
@@ -193,8 +223,15 @@ public function submit()
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
- // Sync to model for domain conflict check
- $this->syncToModel();
+ // Sync to model for domain conflict check (without validation)
+ $this->application->human_name = $this->humanName;
+ $this->application->description = $this->description;
+ $this->application->fqdn = $this->fqdn;
+ $this->application->image = $this->image;
+ $this->application->exclude_from_status = $this->excludeFromStatus;
+ $this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
+ $this->application->is_gzip_enabled = $this->isGzipEnabled;
+ $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@@ -212,7 +249,7 @@ public function submit()
$this->validate();
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
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.');
@@ -224,7 +261,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
- $this->syncFromModel();
+ $this->syncData();
}
return handleError($e, $this);
diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php
index c8029761d..05f786690 100644
--- a/app/Livewire/Project/Shared/HealthChecks.php
+++ b/app/Livewire/Project/Shared/HealthChecks.php
@@ -2,42 +2,54 @@
namespace App\Livewire\Project\Shared;
-use App\Livewire\Concerns\SynchronizesModelData;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Validate;
use Livewire\Component;
class HealthChecks extends Component
{
use AuthorizesRequests;
- use SynchronizesModelData;
public $resource;
// Explicit properties
+ #[Validate(['boolean'])]
public bool $healthCheckEnabled = false;
+ #[Validate(['string'])]
public string $healthCheckMethod;
+ #[Validate(['string'])]
public string $healthCheckScheme;
+ #[Validate(['string'])]
public string $healthCheckHost;
+ #[Validate(['nullable', 'string'])]
public ?string $healthCheckPort = null;
+ #[Validate(['string'])]
public string $healthCheckPath;
+ #[Validate(['integer'])]
public int $healthCheckReturnCode;
+ #[Validate(['nullable', 'string'])]
public ?string $healthCheckResponseText = null;
+ #[Validate(['integer', 'min:1'])]
public int $healthCheckInterval;
+ #[Validate(['integer', 'min:1'])]
public int $healthCheckTimeout;
+ #[Validate(['integer', 'min:1'])]
public int $healthCheckRetries;
+ #[Validate(['integer'])]
public int $healthCheckStartPeriod;
+ #[Validate(['boolean'])]
public bool $customHealthcheckFound = false;
protected $rules = [
@@ -56,36 +68,69 @@ class HealthChecks extends Component
'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();
+ $this->syncData();
+ }
+
+ public function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->validate();
+
+ // Sync to model
+ $this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_method = $this->healthCheckMethod;
+ $this->resource->health_check_scheme = $this->healthCheckScheme;
+ $this->resource->health_check_host = $this->healthCheckHost;
+ $this->resource->health_check_port = $this->healthCheckPort;
+ $this->resource->health_check_path = $this->healthCheckPath;
+ $this->resource->health_check_return_code = $this->healthCheckReturnCode;
+ $this->resource->health_check_response_text = $this->healthCheckResponseText;
+ $this->resource->health_check_interval = $this->healthCheckInterval;
+ $this->resource->health_check_timeout = $this->healthCheckTimeout;
+ $this->resource->health_check_retries = $this->healthCheckRetries;
+ $this->resource->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
+
+ $this->resource->save();
+ } else {
+ // Sync from model
+ $this->healthCheckEnabled = $this->resource->health_check_enabled;
+ $this->healthCheckMethod = $this->resource->health_check_method;
+ $this->healthCheckScheme = $this->resource->health_check_scheme;
+ $this->healthCheckHost = $this->resource->health_check_host;
+ $this->healthCheckPort = $this->resource->health_check_port;
+ $this->healthCheckPath = $this->resource->health_check_path;
+ $this->healthCheckReturnCode = $this->resource->health_check_return_code;
+ $this->healthCheckResponseText = $this->resource->health_check_response_text;
+ $this->healthCheckInterval = $this->resource->health_check_interval;
+ $this->healthCheckTimeout = $this->resource->health_check_timeout;
+ $this->healthCheckRetries = $this->resource->health_check_retries;
+ $this->healthCheckStartPeriod = $this->resource->health_check_start_period;
+ $this->customHealthcheckFound = $this->resource->custom_healthcheck_found;
+ }
}
public function instantSave()
{
$this->authorize('update', $this->resource);
- $this->syncToModel();
+ // Sync component properties to model
+ $this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_method = $this->healthCheckMethod;
+ $this->resource->health_check_scheme = $this->healthCheckScheme;
+ $this->resource->health_check_host = $this->healthCheckHost;
+ $this->resource->health_check_port = $this->healthCheckPort;
+ $this->resource->health_check_path = $this->healthCheckPath;
+ $this->resource->health_check_return_code = $this->healthCheckReturnCode;
+ $this->resource->health_check_response_text = $this->healthCheckResponseText;
+ $this->resource->health_check_interval = $this->healthCheckInterval;
+ $this->resource->health_check_timeout = $this->healthCheckTimeout;
+ $this->resource->health_check_retries = $this->healthCheckRetries;
+ $this->resource->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
}
@@ -96,7 +141,20 @@ public function submit()
$this->authorize('update', $this->resource);
$this->validate();
- $this->syncToModel();
+ // Sync component properties to model
+ $this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_method = $this->healthCheckMethod;
+ $this->resource->health_check_scheme = $this->healthCheckScheme;
+ $this->resource->health_check_host = $this->healthCheckHost;
+ $this->resource->health_check_port = $this->healthCheckPort;
+ $this->resource->health_check_path = $this->healthCheckPath;
+ $this->resource->health_check_return_code = $this->healthCheckReturnCode;
+ $this->resource->health_check_response_text = $this->healthCheckResponseText;
+ $this->resource->health_check_interval = $this->healthCheckInterval;
+ $this->resource->health_check_timeout = $this->healthCheckTimeout;
+ $this->resource->health_check_retries = $this->healthCheckRetries;
+ $this->resource->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
} catch (\Throwable $e) {
@@ -111,7 +169,20 @@ public function toggleHealthcheck()
$wasEnabled = $this->healthCheckEnabled;
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
- $this->syncToModel();
+ // Sync component properties to model
+ $this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_method = $this->healthCheckMethod;
+ $this->resource->health_check_scheme = $this->healthCheckScheme;
+ $this->resource->health_check_host = $this->healthCheckHost;
+ $this->resource->health_check_port = $this->healthCheckPort;
+ $this->resource->health_check_path = $this->healthCheckPath;
+ $this->resource->health_check_return_code = $this->healthCheckReturnCode;
+ $this->resource->health_check_response_text = $this->healthCheckResponseText;
+ $this->resource->health_check_interval = $this->healthCheckInterval;
+ $this->resource->health_check_timeout = $this->healthCheckTimeout;
+ $this->resource->health_check_retries = $this->healthCheckRetries;
+ $this->resource->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
if ($this->healthCheckEnabled && ! $wasEnabled && $this->resource->isRunning()) {
diff --git a/tests/Unit/SynchronizesModelDataTest.php b/tests/Unit/SynchronizesModelDataTest.php
deleted file mode 100644
index 4551fb056..000000000
--- a/tests/Unit/SynchronizesModelDataTest.php
+++ /dev/null
@@ -1,163 +0,0 @@
- 'application.settings.is_static',
- ];
- }
-
- // Expose protected method for testing
- public function testSync(): void
- {
- $this->syncToModel();
- }
- };
-
- // Create real ApplicationSetting instance
- $settings = new ApplicationSetting;
- $settings->is_static = false;
-
- // Create Application instance
- $application = new Application;
- $application->setRelation('settings', $settings);
-
- $component->application = $application;
- $component->is_static = true;
-
- // Sync to model
- $component->testSync();
-
- // Verify the value was set on the model
- expect($component->application->settings->is_static)->toBeTrue();
-});
-
-it('syncs boolean values correctly', function () {
- $component = new class
- {
- use SynchronizesModelData;
-
- public bool $is_spa = true;
-
- public bool $is_build_server_enabled = false;
-
- public Application $application;
-
- protected function getModelBindings(): array
- {
- return [
- 'is_spa' => 'application.settings.is_spa',
- 'is_build_server_enabled' => 'application.settings.is_build_server_enabled',
- ];
- }
-
- public function testSync(): void
- {
- $this->syncToModel();
- }
- };
-
- $settings = new ApplicationSetting;
- $settings->is_spa = false;
- $settings->is_build_server_enabled = true;
-
- $application = new Application;
- $application->setRelation('settings', $settings);
-
- $component->application = $application;
-
- $component->testSync();
-
- expect($component->application->settings->is_spa)->toBeTrue()
- ->and($component->application->settings->is_build_server_enabled)->toBeFalse();
-});
-
-it('syncs from model to component correctly', function () {
- $component = new class
- {
- use SynchronizesModelData;
-
- public bool $is_static = false;
-
- public bool $is_spa = false;
-
- public Application $application;
-
- protected function getModelBindings(): array
- {
- return [
- 'is_static' => 'application.settings.is_static',
- 'is_spa' => 'application.settings.is_spa',
- ];
- }
-
- public function testSyncFrom(): void
- {
- $this->syncFromModel();
- }
- };
-
- $settings = new ApplicationSetting;
- $settings->is_static = true;
- $settings->is_spa = true;
-
- $application = new Application;
- $application->setRelation('settings', $settings);
-
- $component->application = $application;
-
- $component->testSyncFrom();
-
- expect($component->is_static)->toBeTrue()
- ->and($component->is_spa)->toBeTrue();
-});
-
-it('handles properties that do not exist gracefully', function () {
- $component = new class
- {
- use SynchronizesModelData;
-
- public Application $application;
-
- protected function getModelBindings(): array
- {
- return [
- 'non_existent_property' => 'application.name',
- ];
- }
-
- public function testSync(): void
- {
- $this->syncToModel();
- }
- };
-
- $application = new Application;
- $component->application = $application;
-
- // Should not throw an error
- $component->testSync();
-
- expect(true)->toBeTrue();
-});