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 +
+
+ + + + + + + + + + Save Changes + + +
+``` + +**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(); -});