From 57b2c3130f2ef20eebf63a9b4437c4bb9e651c2c Mon Sep 17 00:00:00 2001 From: celikerde Date: Mon, 13 Jul 2026 11:39:19 +0300 Subject: [PATCH 01/26] fix(Translations): add missing translation for company deletion restriction --- lang/en.json | 1 + lang/tr.json | 1 + 2 files changed, 2 insertions(+) diff --git a/lang/en.json b/lang/en.json index 27f78e0c6..f2e12c7c3 100755 --- a/lang/en.json +++ b/lang/en.json @@ -100,6 +100,7 @@ "Terms of Service": "Terms of Service", "Thank you for your support!": "Thank you for your support!", + "This company has registered users and cannot be deleted.": "This company has registered users and cannot be deleted.", "This password reset link will expire in :count minutes.": "This password reset link will expire in :count minutes.", "This verification link will expire in :count minutes.": "This verification link will expire in :count minutes.", "Total": "Total", diff --git a/lang/tr.json b/lang/tr.json index f89d2384c..cb3c88f18 100755 --- a/lang/tr.json +++ b/lang/tr.json @@ -99,6 +99,7 @@ "Terms of Service": "Hizmet Şartları", "Thank you for your support!": "Desteğiniz için teşekkür ederiz!", + "This company has registered users and cannot be deleted.": "Bu şirkete kayıtlı kullanıcılar olduğu için bu şirket silinemez.", "This password reset link will expire in :count minutes.": "Bu şifre oluşturma linki :count dakika içerisinde sonlanacak.", "This verification link will expire in :count minutes.": "Bu doğrulama linki :count dakika içerisinde sonlanacak.", "Total": "Toplam", From 222071df16aa4f2e428b19d0ac413d33272e597e Mon Sep 17 00:00:00 2001 From: celikerde Date: Mon, 13 Jul 2026 11:52:20 +0300 Subject: [PATCH 02/26] fix(Request): add DELETE method handling and validation response --- src/Http/Requests/Request.php | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/Http/Requests/Request.php b/src/Http/Requests/Request.php index f60b80cb3..944f4c28a 100755 --- a/src/Http/Requests/Request.php +++ b/src/Http/Requests/Request.php @@ -3,11 +3,14 @@ namespace Unusualify\Modularous\Http\Requests; use Closure; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; +use Unusualify\Modularity\Services\MessageStage; use Unusualify\Modularous\Traits\ManageTraits; abstract class Request extends FormRequest @@ -45,12 +48,48 @@ public function rules() case 'PUT': return $this->mergeRules(array_merge($this->rulesForAll(), $this->rulesForUpdate())); + case 'DELETE': + return $this->rulesForDelete(); + default:break; } return []; } + public function rulesForDelete() + { + return []; + } + + protected function prepareForValidation() + { + if ($this->method() === 'DELETE' && $this->route()) { + $parameters = $this->route()->parameters(); + + if (! empty($parameters) && ! $this->has('id')) { + $this->merge(['id' => last($parameters)]); + } + } + } + + + protected function failedValidation(Validator $validator) + { + if ($this->method() === 'DELETE') { + $response = response()->json([ + 'message' => $validator->errors()->first(), + 'errors' => $validator->errors(), + 'variant' => MessageStage::ERROR->value, + ], 422); + + throw new ValidationException($validator, $response); + } + + parent::failedValidation($validator); + } + + public function mergeRules($rules) { $locales = getLocales(); From 435c0132bbf0f2d164d98c1ce3d49797e88ed5f7 Mon Sep 17 00:00:00 2001 From: celikerde Date: Mon, 13 Jul 2026 11:53:50 +0300 Subject: [PATCH 03/26] fix(CompanyRequest): add validation rules for DELETE requests --- .../SystemUser/Http/Requests/CompanyRequest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/modules/SystemUser/Http/Requests/CompanyRequest.php b/modules/SystemUser/Http/Requests/CompanyRequest.php index 91a782341..d2fb71b0c 100755 --- a/modules/SystemUser/Http/Requests/CompanyRequest.php +++ b/modules/SystemUser/Http/Requests/CompanyRequest.php @@ -45,6 +45,22 @@ public function rulesForUpdate() ]; } + public function rulesForDelete() + { + return [ + 'id' => [ + 'required', + function ($attribute, $value, $fail) { + $company = $this->model()->find($value); + + if ($company && $company->users()->exists()) { + $fail(__('This company has registered users and cannot be deleted.')); + } + }, + ], + ]; + } + public function messages() { return [ From b24fbba84f894046f69ce241771487a93e6512df Mon Sep 17 00:00:00 2001 From: celikerde Date: Mon, 13 Jul 2026 11:55:02 +0300 Subject: [PATCH 04/26] fix(BaseController): add form request class retrieval in destroy method --- src/Http/Controllers/BaseController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Http/Controllers/BaseController.php b/src/Http/Controllers/BaseController.php index abca6ad3a..5b99358f2 100755 --- a/src/Http/Controllers/BaseController.php +++ b/src/Http/Controllers/BaseController.php @@ -388,6 +388,8 @@ public function update($id, $submoduleId = null) */ public function destroy($id, $submoduleId = null) { + $this->getFormRequestClass(); + $params = $this->request->route()->parameters(); $id = last($params); From 1d763b04a235a7616aa37270ef9ec073527366af Mon Sep 17 00:00:00 2001 From: celikerde Date: Mon, 13 Jul 2026 11:57:12 +0300 Subject: [PATCH 05/26] fix(useTable): handle 422 status response and update axios delete/put requests to validate status --- vue/src/js/hooks/useTable.js | 6 +++++- vue/src/js/store/api/datatable.js | 14 ++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/vue/src/js/hooks/useTable.js b/vue/src/js/hooks/useTable.js index 7b45023d5..712043610 100755 --- a/vue/src/js/hooks/useTable.js +++ b/vue/src/js/hooks/useTable.js @@ -847,7 +847,11 @@ export default function useTable (props, context) { } let successCallback = (res) => { - if(res.status === 200) { + if (res.status === 422) { + if (runAlert && res.data?.message) { + store.dispatch(ACTIONS.SHOW_ALERT, res.data) + } + } else if (res.status === 200) { if(runAlert && res.data.variant && res.data.message){ store.dispatch(ACTIONS.SHOW_ALERT, res.data) diff --git a/vue/src/js/store/api/datatable.js b/vue/src/js/store/api/datatable.js index 87625a6e3..2b3c96ec8 100755 --- a/vue/src/js/store/api/datatable.js +++ b/vue/src/js/store/api/datatable.js @@ -53,7 +53,10 @@ export default { // const url = window[import.meta.env.VUE_APP_NAME].ENDPOINTS.destroy.replace(':id', id) // var url = window[import.meta.env.VUE_APP_NAME].ENDPOINTS.index.replace(':id', item.id); url = url.replace(':id', id) - axios.delete(url).then(function (resp) { + console.log(url) + axios.delete(url, { + validateStatus: status => (status >= 200 && status < 300) || status === 422 + }).then(function (resp) { if (callback && typeof callback === 'function') callback(resp) }, function (resp) { const error = { @@ -68,7 +71,9 @@ export default { forceDelete (url, id, callback, errorCallback) { // const url = window[import.meta.env.VUE_APP_NAME].ENDPOINTS.forceDelete url = url.replace(':id', id) - axios.put(url, { id }).then(function (resp) { + axios.put(url, { id }, { + validateStatus: status => (status >= 200 && status < 300) || status === 422 + }).then(function (resp) { if (callback && typeof callback === 'function') callback(resp) }, function (resp) { const error = { @@ -83,7 +88,9 @@ export default { restore (url, id, callback) { // const url = window[import.meta.env.VUE_APP_NAME].ENDPOINTS.restore url = url.replace(':id', id) - axios.put(url, { id }).then(function (resp) { + axios.put(url, { id }, { + validateStatus: status => (status >= 200 && status < 300) || status === 422 + }).then(function (resp) { if (callback && typeof callback === 'function') callback(resp) }, function (resp) { const error = { @@ -121,7 +128,6 @@ export default { if (errorCallback && typeof errorCallback === 'function') errorCallback(error) }) }, - bulkPublish (url, params, callback) { // const url = window[import.meta.env.VUE_APP_NAME].CMS_URLS.bulkPublish axios.post(url, { ids: params.ids, publish: params.toPublish }).then(function (resp) { From 87323fa488b029479903646fcdbb7857ca90d9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Mon, 13 Jul 2026 14:03:28 +0300 Subject: [PATCH 06/26] refactor(Modularous): remove redundant vendorPath check and clean up translation caching logic --- src/Modularous.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Modularous.php b/src/Modularous.php index 65ca03570..281a40f1a 100755 --- a/src/Modularous.php +++ b/src/Modularous.php @@ -84,8 +84,6 @@ public function __construct(Container $app, $path = null) if (isset($versions['unusualify/modularous'])) { $this->vendorPath = realpath($versions['unusualify/modularous']['install_path']); - } elseif (isset($versions['unusualify/modularous'])) { - $this->vendorPath = realpath($versions['unusualify/modularous']['install_path']); } else { throw new \Exception('Modularous or Modularous not found in composer.json'); } @@ -541,10 +539,6 @@ public function getTranslations() $cache = Cache::store('file'); - if ($cache->has($cache_key) && false) { - return $cache->get($cache_key); - } - $translations = app('translator')->getTranslations(); $cache->set($cache_key, json_encode($translations), 600); From 67267a6eb20f2ab7d52a1f69a2a120a973573757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Mon, 13 Jul 2026 14:03:39 +0300 Subject: [PATCH 07/26] refactor(composer): improve installed composer retrieval logic and handle missing files gracefully --- src/Helpers/composer.php | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/Helpers/composer.php b/src/Helpers/composer.php index a5b06bcfb..9bbb9c84a 100644 --- a/src/Helpers/composer.php +++ b/src/Helpers/composer.php @@ -6,22 +6,30 @@ if (! function_exists('get_installed_composer')) { function get_installed_composer() { + static $installed = null; + + if ($installed !== null) { + return $installed; + } + + $candidates = []; if (isset($GLOBALS['_composer_bin_dir'])) { - $installedPath = realpath(concatenate_path($GLOBALS['_composer_bin_dir'], '../composer/installed.php')); - } else { - // If we are in Testbench, base_path() points to the skeleton app - // We want to find the vendor directory of the package/project - $vendorPath = base_path('vendor'); - if (! file_exists($vendorPath)) { - $vendorPath = realpath(__DIR__ . '/../../vendor'); - } - $installedPath = $vendorPath . '/composer/installed.php'; + $candidates[] = realpath(concatenate_path($GLOBALS['_composer_bin_dir'], '../composer/installed.php')); } - $installed = require $installedPath; + $candidates[] = base_path('vendor/composer/installed.php'); + $candidates[] = realpath(__DIR__ . '/../../vendor/composer/installed.php'); + + foreach (array_filter($candidates) as $installedPath) { + if (is_file($installedPath)) { + $installed = require $installedPath; + + return $installed; + } + } - return $installed; + throw new \RuntimeException('Composer installed.php could not be located.'); } } From 8a7c2cfebca45e5d6ef8f514763c82eccaf780c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Mon, 13 Jul 2026 14:05:48 +0300 Subject: [PATCH 08/26] test(TestModule): add console command and middleware, update module configuration with aliases --- .../TestModule/Console/TestModuleCommand.php | 17 + .../Http/Middleware/ItemMiddleware.php | 14 + test-modules/TestModule/module.json | 13 +- tests/ModularousTest.php | 291 +++++++++++++-- tests/ModuleTest.php | 331 ++++++++++++++++++ .../Providers/LaravelServiceProviderTest.php | 111 ++++++ tests/Services/AssetsTest.php | 26 ++ .../DependentCacheInvalidatorModulesTest.php | 49 +++ .../Cache/DependentCacheInvalidatorTest.php | 99 +++--- .../ReentrantDependentCacheInvalidator.php | 22 ++ .../Stubs/StubModelWithMethodDependents.php | 25 ++ .../Stubs/StubModelWithPropertyDependents.php | 20 ++ .../Stubs/StubModelWithoutDependents.php | 15 + .../SystemPricingCurrencyProviderTest.php | 92 +++++ tests/Services/FileLibrary/DiskTest.php | 39 +++ .../Services/FileLibrary/FileServiceTest.php | 19 + tests/Services/FilepondManagerTest.php | 150 ++++++++ .../MediaLibrary/ImageServiceTest.php | 19 + .../RemoteApiConfigurationExceptionTest.php | 65 ++++ .../RemoteApiConnectorFactoryTest.php | 67 ++++ .../Services/Uploader/SignAzureUploadTest.php | 82 ++++- .../View/ModularousNavigationTest.php | 50 +++ tests/Services/View/UWidgetConnectorTest.php | 153 ++++++++ tests/Services/View/UWrapperTest.php | 33 ++ 24 files changed, 1709 insertions(+), 93 deletions(-) create mode 100644 test-modules/TestModule/Console/TestModuleCommand.php create mode 100644 test-modules/TestModule/Http/Middleware/ItemMiddleware.php create mode 100644 tests/Providers/LaravelServiceProviderTest.php create mode 100644 tests/Services/Cache/DependentCacheInvalidatorModulesTest.php create mode 100644 tests/Services/Cache/Stubs/ReentrantDependentCacheInvalidator.php create mode 100644 tests/Services/Cache/Stubs/StubModelWithMethodDependents.php create mode 100644 tests/Services/Cache/Stubs/StubModelWithPropertyDependents.php create mode 100644 tests/Services/Cache/Stubs/StubModelWithoutDependents.php create mode 100644 tests/Services/Currency/SystemPricingCurrencyProviderTest.php create mode 100644 tests/Services/FileLibrary/DiskTest.php create mode 100644 tests/Services/FileLibrary/FileServiceTest.php create mode 100644 tests/Services/MediaLibrary/ImageServiceTest.php create mode 100644 tests/Services/RemoteApi/RemoteApiConfigurationExceptionTest.php create mode 100644 tests/Services/RemoteApi/RemoteApiConnectorFactoryTest.php create mode 100644 tests/Services/View/UWidgetConnectorTest.php diff --git a/test-modules/TestModule/Console/TestModuleCommand.php b/test-modules/TestModule/Console/TestModuleCommand.php new file mode 100644 index 000000000..ab0f0d16a --- /dev/null +++ b/test-modules/TestModule/Console/TestModuleCommand.php @@ -0,0 +1,17 @@ +set('modules.cache.enabled', true); + $app['config']->set('modules.cache.driver', 'array'); + $app['config']->set('modules.cache.key', 'modularous-test-format-cache'); + $app['config']->set('modules.cache.lifetime', 600); $path = $app['config']->get('modules.paths.modules'); @@ -47,6 +56,71 @@ public function test_format_cached_on_cache_enabled() $this->assertArrayHasKey('testmodule', $allModules); } + public function test_get_cached_reads_from_cache_store_after_first_population(): void + { + $this->app['config']->set('modules.cache.enabled', true); + $this->app['config']->set('modules.cache.driver', 'array'); + $this->app['config']->set('modules.cache.key', 'modularous-test-get-cached'); + $this->app['config']->set('modules.cache.lifetime', 600); + + $modularous = new Modularous($this->app, $this->app['config']->get('modules.paths.modules')); + $method = new \ReflectionMethod($modularous, 'getCached'); + $method->setAccessible(true); + + $first = $method->invoke($modularous); + $this->assertIsArray($first); + $this->assertTrue($this->app['cache']->store('array')->has('modularous-test-get-cached')); + + $second = $method->invoke($modularous); + $this->assertSame($first, $second); + } + + public function test_format_cached_resets_when_cached_path_is_outside_application(): void + { + $modularous = new Modularous($this->app, $this->app['config']->get('modules.paths.modules')); + $method = new \ReflectionMethod($modularous, 'formatCached'); + $method->setAccessible(true); + + $modules = $method->invoke($modularous, [ + 'ghost' => ['path' => '/tmp/not-under-base-path/module'], + ]); + + $this->assertArrayHasKey('testmodule', $modules); + $this->assertArrayHasKey('systemmodule', $modules); + } + + public function test_format_cached_reuses_valid_cached_module_paths(): void + { + $modularous = new Modularous($this->app, $this->app['config']->get('modules.paths.modules')); + $method = new \ReflectionMethod($modularous, 'formatCached'); + $method->setAccessible(true); + + $testModulePath = $modularous->find('TestModule')->getPath(); + $modules = $method->invoke($modularous, [ + 'testmodule' => ['path' => $testModulePath], + ]); + + $this->assertArrayHasKey('testmodule', $modules); + $this->assertSame($testModulePath, $modules['testmodule']->getPath()); + } + + public function test_all_uses_cached_modules_when_cache_enabled_and_not_in_console(): void + { + $this->app['config']->set('modules.cache.enabled', true); + $this->app['config']->set('modules.cache.driver', 'array'); + $this->app['config']->set('modules.cache.key', 'modularous-test-all-cache'); + $this->app['config']->set('modules.cache.lifetime', 600); + + $app = Mockery::mock($this->app)->makePartial(); + $app->shouldReceive('runningInConsole')->andReturn(false); + + $modularous = new Modularous($app, $this->app['config']->get('modules.paths.modules')); + $modules = $modularous->all(); + + $this->assertArrayHasKey('testmodule', $modules); + $this->assertArrayHasKey('systemmodule', $modules); + } + public function test_has_module() { $this->assertTrue($this->modularous->has('systemmodule')); @@ -66,6 +140,26 @@ public function test_has_module() // $this->assertArrayNotHasKey('testmodule', $activeModules); // } + public function test_get_by_status_returns_only_matching_modules(): void + { + $this->writeModuleActivationStatuses([ + 'TestModule' => false, + 'SystemModule' => true, + ]); + + $activeModules = $this->modularous->getByStatus(true); + $inactiveModules = $this->modularous->getByStatus(false); + + $this->assertArrayHasKey('systemmodule', $activeModules); + $this->assertArrayNotHasKey('testmodule', $activeModules); + $this->assertArrayHasKey('testmodule', $inactiveModules); + } + + public function test_get_auth_guard_name(): void + { + $this->assertSame('modularous', Modularous::getAuthGuardName()); + } + public function test_development_production() { $this->assertFalse($this->modularous->isDevelopment()); @@ -76,6 +170,14 @@ public function test_feature_methods() { $this->assertTrue($this->modularous->shouldUseInertia()); + $this->app['config']->set('modularous.use_collation_for_search', true); + $this->app['config']->set('modularous.include_transaction_fee', true); + $this->app['config']->set('modularous.use_country_based_vat_rates', true); + + $this->assertTrue($this->modularous->shouldUseCollationForSearch()); + $this->assertTrue($this->modularous->shouldIncludeTransactionFee()); + $this->assertTrue($this->modularous->shouldUseCountryBasedVatRates()); + $this->assertEquals(config('app.name'), $this->modularous->pageTitle()); Modularous::createPageTitle(fn () => 'Test Page Title'); $this->assertEquals('Test Page Title', $this->modularous->pageTitle()); @@ -103,6 +205,26 @@ public function test_clear_cache() $this->assertFalse($this->app['cache']->has('test-modules-cache')); } + public function test_clear_cache_flushes_activator_cache_when_supported(): void + { + $activator = new class($this->app) extends ModularousActivator + { + public int $flushCount = 0; + + public function flushCache(): void + { + $this->flushCount++; + } + }; + + $this->app->instance(ActivatorInterface::class, $activator); + + $modularous = new Modularous($this->app, $this->app['config']->get('modules.paths.modules')); + $modularous->clearCache(); + + $this->assertSame(1, $activator->flushCount); + } + public function test_disable_cache() { $this->modularous->disableCache(); @@ -131,22 +253,26 @@ public function test_get_modules_path() public function test_set_and_revert_system_modules_path() { - // Skip if production - if ($this->modularous->isProduction()) { - $this->expectException(ModularousSystemPathException::class); - $this->modularous->setSystemModulesPath(); - } else { - $originalPath = config('modules.paths.modules'); + $this->expectException(ModularousSystemPathException::class); + $this->modularous->setSystemModulesPath(); + } + + public function test_revert_system_modules_path_restores_retained_modules_path(): void + { + $retainedPath = config('modules.paths.modules'); - $this->modularous->setSystemModulesPath(); - $newPath = config('modules.paths.modules'); - $this->assertNotEquals($originalPath, $newPath); - $this->assertStringContainsString('modules', $newPath); + config(['modules.paths.modules' => '/tmp/changed-modules-path']); - $this->modularous->revertSystemModulesPath(); - $revertedPath = config('modules.paths.modules'); - $this->assertEquals($originalPath, $revertedPath); - } + $this->modularous->revertSystemModulesPath(); + + $this->assertSame($retainedPath, config('modules.paths.modules')); + } + + public function test_get_app_url(): void + { + $this->app['config']->set('modularous.app_url', 'http://example.test'); + + $this->assertSame('http://example.test', $this->modularous->getAppUrl()); } public function test_get_app_host() @@ -188,6 +314,25 @@ public function test_is_panel_url_with_admin_path() $this->assertFalse($this->modularous->isPanelUrl('http://localhost/home')); } + public function test_is_panel_url_returns_false_when_request_has_no_segment_and_url_is_provided(): void + { + $this->app['config']->set('modularous.app_url', 'http://localhost'); + $this->app['config']->set('modularous.admin_app_url', ''); + $this->app['config']->set('modularous.admin_app_path', 'admin'); + + $request = Request::create('http://localhost', 'GET'); + $this->app->instance('request', $request); + + $this->assertFalse($this->modularous->isPanelUrl('http://localhost/admin/dashboard')); + } + + public function test_get_admin_url_prefix_returns_false_when_using_admin_subdomain(): void + { + $this->app['config']->set('modularous.admin_app_url', 'http://admin.localhost'); + + $this->assertFalse($this->modularous->getAdminUrlPrefix()); + } + public function test_is_modularous_route() { $this->app['config']->set('modularous.admin_route_name_prefix', 'admin'); @@ -213,30 +358,27 @@ public function test_get_system_route_name_prefix() public function test_get_translations() { - try { - $translations = $this->modularous->getTranslations(); - $this->assertIsArray($translations); - } catch (\UnexpectedValueException $e) { - // Translation directory might not exist in test environment, which is acceptable - $this->assertTrue(true); - } + // Use a real translator binding (instance, not facade mock) so the + // assertion is deterministic without fragile facade expectations that + // break when the full suite runs. + $translator = Mockery::mock(\Illuminate\Contracts\Translation\Translator::class); + $translator->shouldReceive('getTranslations')->andReturn(['en' => ['greeting' => 'Hello']]); + $this->app->instance('translator', $translator); + + Cache::store('file')->forget('modularous-languages'); + + $translations = $this->modularous->getTranslations(); + + $this->assertSame(['en' => ['greeting' => 'Hello']], $translations); } public function test_clear_translations() { - try { - // Populate translations cache - $this->modularous->getTranslations(); + Cache::put('modularous-languages', ['cached'], 600); - // Clear translations - $this->modularous->clearTranslations(); + $this->modularous->clearTranslations(); - // Verify it doesn't throw errors - $this->assertTrue(true); - } catch (\UnexpectedValueException $e) { - // Translation directory might not exist in test environment, which is acceptable - $this->assertTrue(true); - } + $this->assertFalse(Cache::has('modularous-languages')); } public function test_get_grouped_modules() @@ -344,4 +486,89 @@ public function test_should_use_language_based_prices_without_callback() $shouldUse = $this->modularous->shouldUseLanguageBasedPrices(); $this->assertFalse($shouldUse); } + + public function test_get_currency_for_language_based_prices_returns_false_when_disabled(): void + { + Modularous::createDisableLanguageBasedPrices(null); + $this->app['config']->set('modularous.use_language_based_prices', false); + + $this->assertFalse($this->modularous->getCurrencyForLanguageBasedPrices()); + } + + public function test_get_currency_for_language_based_prices_returns_false_when_provider_unavailable(): void + { + $this->app['config']->set('modularous.use_language_based_prices', true); + + $provider = Mockery::mock(CurrencyProviderInterface::class); + $provider->shouldReceive('isAvailable')->once()->andReturn(false); + $this->app->instance(CurrencyProviderInterface::class, $provider); + + $this->assertFalse($this->modularous->getCurrencyForLanguageBasedPrices()); + } + + public function test_get_currency_for_language_based_prices_returns_currency_for_locale(): void + { + $this->app['config']->set('modularous.use_language_based_prices', true); + $this->app['config']->set('modularous.language_currencies', ['en' => 'USD']); + $this->app->setLocale('en'); + + $currency = (object) ['id' => 1, 'iso_4217' => 'USD']; + $provider = Mockery::mock(CurrencyProviderInterface::class); + $provider->shouldReceive('isAvailable')->once()->andReturn(true); + $provider->shouldReceive('findByIso4217')->once()->with('USD')->andReturn($currency); + $this->app->instance(CurrencyProviderInterface::class, $provider); + + $this->assertSame($currency, $this->modularous->getCurrencyForLanguageBasedPrices()); + } + + public function test_get_currency_for_language_based_prices_returns_false_without_locale_mapping(): void + { + $this->app['config']->set('modularous.use_language_based_prices', true); + $this->app['config']->set('modularous.language_currencies', []); + $this->app->setLocale('en'); + + $provider = Mockery::mock(CurrencyProviderInterface::class); + $provider->shouldReceive('isAvailable')->once()->andReturn(true); + $this->app->instance(CurrencyProviderInterface::class, $provider); + + $this->assertFalse($this->modularous->getCurrencyForLanguageBasedPrices()); + } + + public function test_get_models_finds_enabled_module_entities(): void + { + $models = $this->modularous->getModels('Item'); + + $this->assertContains(TestModuleItem::class, $models); + } + + public function test_get_module_route_model_select_items_and_resolve_target(): void + { + $items = $this->modularous->getModuleRouteModelSelectItems(); + + $this->assertNotEmpty($items); + + $testModuleItem = collect($items)->first( + fn (array $item): bool => $item['value'] === TestModuleItem::class + ); + + $this->assertNotNull($testModuleItem); + $this->assertSame('TestModule - Item', $testModuleItem['title']); + + $this->assertSame( + 'TestModule::Item', + $this->modularous->resolveTargetModuleRouteForModelClass(TestModuleItem::class) + ); + $this->assertNull($this->modularous->resolveTargetModuleRouteForModelClass('Unknown\\Model')); + } + + public function test_get_module_route_model_select_items_supports_trait_filters(): void + { + $parentSegmentOnly = $this->modularous->getModuleRouteModelSelectItems(true, false); + $pageLayoutOnly = $this->modularous->getModuleRouteModelSelectItems(false, true); + $both = $this->modularous->getModuleRouteModelSelectItems(true, true); + + $this->assertIsArray($parentSegmentOnly); + $this->assertIsArray($pageLayoutOnly); + $this->assertIsArray($both); + } } diff --git a/tests/ModuleTest.php b/tests/ModuleTest.php index 2ccde0e8d..0943a45f4 100644 --- a/tests/ModuleTest.php +++ b/tests/ModuleTest.php @@ -4,6 +4,9 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; use Mockery; use Unusualify\Modularous\Exceptions\ModularousException; use Unusualify\Modularous\Facades\Modularous; @@ -29,6 +32,8 @@ protected function getEnvironmentSetUp($app) $app['config']->set('modules.paths.generator.model', ['path' => 'Entities', 'namespace' => 'Entities', 'generate' => false]); $app['config']->set('modules.paths.generator.repository', ['path' => 'Repositories', 'namespace' => 'Repositories', 'generate' => false]); $app['config']->set('modules.paths.generator.controller', ['path' => 'Controllers', 'namespace' => 'Controllers', 'generate' => false]); + $app['config']->set('modules.paths.generator.filter', ['path' => 'Http/Middleware', 'namespace' => 'Http\\Middleware', 'generate' => true]); + $app['config']->set('modules.paths.generator.command', ['path' => 'Console', 'generate' => false]); Modularous::boot(); } @@ -479,4 +484,330 @@ public function test_is_status_throws_modularous_exception_when_activator_fails( $this->module->isStatus(true); } + + /** + * @param array $config + */ + private function replaceModuleConfig(array $config): void + { + Config::set('test_module', $config); + + $property = new \ReflectionProperty($this->module, 'rawConfigCache'); + $property->setAccessible(true); + $property->setValue($this->module, $config); + } + + private function registerNamedRoute(string $method, string $uri, string $name): void + { + Route::$method($uri, static fn () => 'ok')->name($name); + Route::getRoutes()->refreshNameLookups(); + } + + public function test_is_status_returns_true_when_activator_reports_enabled(): void + { + $activator = Mockery::mock($this->module->getActivator()); + $activator->shouldReceive('hasStatus')->once()->with($this->module, true)->andReturnTrue(); + + $property = new \ReflectionProperty($this->module, 'moduleActivator'); + $property->setAccessible(true); + $property->setValue($this->module, $activator); + + $this->assertTrue($this->module->isStatus(true)); + } + + public function test_register_aliases_registers_manifest_aliases(): void + { + $this->module->registerAliases(); + + $aliases = \Illuminate\Foundation\AliasLoader::getInstance()->getAliases(); + + $this->assertSame( + 'TestModules\TestModule\Entities\Item', + $aliases['TestModuleItemAlias'] ?? null + ); + } + + public function test_set_middlewares_discovers_item_middleware(): void + { + $property = new \ReflectionProperty($this->module, 'middlewares'); + $property->setAccessible(true); + $middlewares = $property->getValue($this->module); + + $this->assertArrayHasKey('item', $middlewares); + $this->assertSame('modules.test_module.item', $middlewares['item']['alias']); + $this->assertSame( + 'TestModules\TestModule\Http\Middleware\ItemMiddleware', + $middlewares['item']['class'] + ); + } + + public function test_create_middleware_aliases_registers_discovered_middleware(): void + { + $this->module->createMiddlewareAliases(); + + $router = $this->app->make('router'); + + $this->assertSame( + 'TestModules\TestModule\Http\Middleware\ItemMiddleware', + $router->getMiddleware()['modules.test_module.item'] ?? null + ); + } + + public function test_get_route_middleware_aliases_includes_auto_middleware(): void + { + $aliases = $this->module->getRouteMiddlewareAliases('Item'); + + $this->assertContains('modules.test_module.item', $aliases); + } + + public function test_load_commands_registers_console_command(): void + { + \Illuminate\Console\Application::forgetBootstrappers(); + + $this->module->loadCommands(); + + $artisan = new \Illuminate\Console\Application( + $this->app, + $this->app->make('events'), + '11.0' + ); + + $this->assertTrue($artisan->has('test-module:ping')); + + \Illuminate\Console\Application::forgetBootstrappers(); + } + + public function test_flush_module_cache_when_cache_enabled(): void + { + Config::set('modularous.cache.enabled', true); + + $method = new \ReflectionMethod($this->module, 'flushModuleCache'); + $method->setAccessible(true); + $method->invoke($this->module); + + $this->addToAssertionCount(1); + } + + public function test_prefix_and_full_prefix_with_system_prefix(): void + { + $config = $this->module->getRawConfig(); + $config['system_prefix'] = true; + $this->replaceModuleConfig($config); + + $this->assertStringContainsString(systemUrlPrefix(), $this->module->fullPrefix()); + $this->assertNotEmpty($this->module->prefix()); + $this->assertStringContainsString( + $this->module->systemRouteNamePrefix(), + $this->module->fullRouteNamePrefix() + ); + } + + public function test_prefix_uses_parent_route_url_when_parent_exists(): void + { + $config = $this->module->getRawConfig(); + $config['routes'] = [ + 'parent' => [ + 'name' => 'Parent', + 'url' => 'parent-items', + 'parent' => true, + ], + 'item' => $this->module->getRawRouteConfig('Item'), + ]; + $this->replaceModuleConfig($config); + + $this->assertSame('parent-items', $this->module->prefix()); + $this->assertTrue($this->module->hasParentRoute()); + $this->assertTrue($this->module->isParentRoute('Parent')); + } + + public function test_get_route_configs_valid_filters_nameless_entries(): void + { + $this->module->loadConfig(); + $this->module->setConfig([ + 'valid' => ['name' => 'Valid'], + 'invalid' => ['headline' => 'Missing name'], + ], 'routes'); + + $validOnly = $this->module->getRouteConfigs(valid: true); + + $this->assertArrayHasKey('valid', $validOnly); + $this->assertArrayNotHasKey('invalid', $validOnly); + } + + public function test_get_repository_returns_false_for_unknown_route(): void + { + $repository = $this->module->getRepository('UnknownRoute'); + + $this->assertNotInstanceOf(\Unusualify\Modularous\Repositories\Repository::class, $repository); + } + + public function test_route_has_table_when_table_exists(): void + { + Schema::create('test_module_items', function (Blueprint $table) { + $table->id(); + $table->string('name')->nullable(); + }); + + $this->assertTrue($this->module->routeHasTable('Item')); + } + + public function test_route_has_table_uses_notation_fallback(): void + { + Schema::create('test_module_items', function (Blueprint $table) { + $table->id(); + $table->string('name')->nullable(); + }); + + $this->assertTrue($this->module->routeHasTable(null, 'Item')); + } + + public function test_route_has_table_returns_false_without_repository(): void + { + $this->assertFalse($this->module->routeHasTable('MissingRoute')); + } + + public function test_get_route_action_url_returns_absolute_fallback_url(): void + { + $routeName = $this->module->fullRouteNamePrefix() . '.item.show'; + $uri = trim($this->module->fullPrefix() . '/items/{item}', '/'); + + $this->registerNamedRoute('get', $uri, $routeName); + + $url = $this->module->getRouteActionUrl('Item', 'show', [], true, false); + + $this->assertStringStartsWith('http://', $url); + $this->assertStringContainsString('/items/', $url); + } + + public function test_user_has_permission_returns_true_for_authenticated_user(): void + { + $user = Mockery::mock(); + $user->shouldReceive('hasPermission')->once()->with('item_view')->andReturnTrue(); + + Modularous::shouldReceive('getAuthGuardName')->andReturn('modularous'); + Auth::shouldReceive('guard')->with('modularous')->andReturnSelf(); + Auth::shouldReceive('user')->andReturn($user); + + $this->assertTrue($this->module->userHasPermission('view', 'Item')); + } + + public function test_get_route_action_url_returns_registered_panel_route(): void + { + $routeName = $this->module->fullRouteNamePrefix() . '.item.index'; + $uri = trim($this->module->fullPrefix() . '/items', '/'); + + $this->registerNamedRoute('get', $uri, $routeName); + + $url = $this->module->getRouteActionUrl('Item', 'index', [], false, false); + + $this->assertSame('/' . $uri, $url); + } + + public function test_get_route_action_url_falls_back_when_route_helper_fails(): void + { + $routeName = $this->module->fullRouteNamePrefix() . '.item.show'; + $uri = trim($this->module->fullPrefix() . '/items/{item}', '/'); + + $this->registerNamedRoute('get', $uri, $routeName); + + $url = $this->module->getRouteActionUrl('Item', 'show', ['item' => 5], false, false); + + $this->assertSame('/' . str_replace('{item}', '5', $uri), $url); + } + + public function test_get_route_panel_urls_without_prefix_and_with_binding(): void + { + $routeName = rtrim($this->module->panelRouteNamePrefix(), '.') . '.item.edit'; + $uri = trim($this->module->fullPrefix() . '/items/{item}/edit', '/'); + + $this->registerNamedRoute('get', $uri, $routeName); + + $withoutPrefix = $this->module->getRoutePanelUrls('Item', true); + $this->assertArrayHasKey('edit', $withoutPrefix); + + $withBinding = $this->module->getRoutePanelUrls('Item', true, '42'); + $this->assertStringContainsString('42', (string) ($withBinding['edit'] ?? '')); + } + + public function test_get_navigation_actions_includes_custom_and_nested_actions(): void + { + $config = $this->module->getRawConfig(); + $config['routes']['item']['table_row_actions'] = [ + [ + 'name' => 'custom', + 'url' => '/custom-action', + ], + ]; + $config['routes']['comment'] = [ + 'name' => 'Comment', + 'belongs' => ['item'], + 'url' => 'comments', + ]; + $this->replaceModuleConfig($config); + + $nestedRouteName = rtrim($this->module->panelRouteNamePrefix(), '.') . '.item.nested.comment.index'; + $nestedUri = trim($this->module->fullPrefix() . '/comments/{item}', '/'); + $this->registerNamedRoute('get', $nestedUri, $nestedRouteName); + + $actions = $this->module->getNavigationActions('Item'); + + $this->assertNotEmpty($actions); + $this->assertSame('custom', $actions[0]['name']); + $this->assertSame('/custom-action', $actions[0]['url']); + $this->assertSame('link', $actions[1]['name'] ?? null); + } + + public function test_get_module_urls_includes_parent_route_name_pattern(): void + { + $config = $this->module->getRawConfig(); + $config['routes']['parent'] = [ + 'name' => 'Parent', + 'route_name' => 'parent_route', + 'parent' => true, + ]; + $this->replaceModuleConfig($config); + + $this->registerNamedRoute('get', '/parent-route/items', 'parent_route.item.index'); + + $urls = $this->module->getModuleUrls(); + + $this->assertArrayHasKey('parent_route.item.index', $urls); + } + + public function test_get_module_urls_includes_system_prefix_with_parent_route_name(): void + { + $config = $this->module->getRawConfig(); + $config['system_prefix'] = true; + $config['routes']['parent'] = [ + 'name' => 'Parent', + 'route_name' => 'parent_route', + 'parent' => true, + ]; + $this->replaceModuleConfig($config); + + $routeName = $this->module->systemRouteNamePrefix() . '.parent_route.item.index'; + $this->registerNamedRoute('get', '/system-parent/items', $routeName); + + $urls = $this->module->getModuleUrls(); + + $this->assertArrayHasKey($routeName, $urls); + } + + public function test_get_raw_config_reads_from_repository_config_when_present(): void + { + Config::set('test_module', ['from' => 'repository']); + + $freshModule = MockModuleManager::getTestModule(); + + $this->assertSame('repository', $freshModule->getRawConfig('from')); + } + + public function test_is_modularous_module_detects_vendor_module_path(): void + { + $vendorModulePath = Modularous::getVendorPath('modules') . '/SystemUser'; + $vendorModule = new Module($this->app, 'SystemUser', $vendorModulePath); + + $this->assertTrue($vendorModule->isModularousModule()); + $this->assertFalse($this->module->isModularousModule()); + } } diff --git a/tests/Providers/LaravelServiceProviderTest.php b/tests/Providers/LaravelServiceProviderTest.php new file mode 100644 index 000000000..8835060af --- /dev/null +++ b/tests/Providers/LaravelServiceProviderTest.php @@ -0,0 +1,111 @@ +app); + + $provider->register(); + + $this->assertTrue(true); + } + + public function test_boot_registers_all_publish_groups(): void + { + $provider = new LaravelServiceProvider($this->app); + $provider->boot(); + + $publishes = $this->publishedPaths(LaravelServiceProvider::class); + + $this->assertNotEmpty($publishes); + $this->assertTrue( + (bool) array_filter( + array_keys($publishes), + static fn (string $source): bool => str_ends_with($source, 'config/publishes/modules.php') + ) + ); + $this->assertTrue( + (bool) array_filter( + array_keys($publishes), + static fn (string $source): bool => str_ends_with($source, '/lang') + ) + ); + $this->assertArrayHasKey(Modularous::getVendorPath('operations'), $publishes); + } + + public function test_publish_migrations_registers_modularous_migrations(): void + { + $provider = new LaravelServiceProvider($this->app); + + $method = new ReflectionMethod($provider, 'publishMigrations'); + $method->setAccessible(true); + $method->invoke($provider); + + $publishes = $this->publishedPaths(LaravelServiceProvider::class, 'modularous-migrations'); + + $this->assertArrayHasKey( + Modularous::getVendorPath('database/migrations/default'), + $publishes + ); + $this->assertSame( + $this->app->databasePath('migrations'), + $publishes[Modularous::getVendorPath('database/migrations/default')] + ); + } + + public function test_publish_ignored_lang_uses_package_lang_when_app_lang_missing(): void + { + $provider = new LaravelServiceProvider($this->app); + + $method = new ReflectionMethod($provider, 'publishIgnoredLang'); + $method->setAccessible(true); + $method->invoke($provider); + + $publishes = $this->publishedPaths(LaravelServiceProvider::class, 'ignored-lang'); + + $expectedSource = is_dir(base_path('lang')) + ? base_path('lang') + : __DIR__ . '/../../lang'; + + $this->assertArrayHasKey($expectedSource, $publishes); + $this->assertSame(base_path('modularous/lang'), $publishes[$expectedSource]); + } + + /** + * @return array + */ + private function publishedPaths(string $providerClass, ?string $group = null): array + { + $property = (new ReflectionClass(BaseServiceProvider::class))->getProperty('publishes'); + $property->setAccessible(true); + + /** @var array> $all */ + $all = $property->getValue(); + + $paths = $all[$providerClass] ?? []; + + if ($group === null) { + return $paths; + } + + $groupsProperty = (new ReflectionClass(BaseServiceProvider::class))->getProperty('publishGroups'); + $groupsProperty->setAccessible(true); + + /** @var array> $groups */ + $groups = $groupsProperty->getValue(); + + return $groups[$group] ?? []; + } +} diff --git a/tests/Services/AssetsTest.php b/tests/Services/AssetsTest.php index e4a95980f..b1b2835ff 100644 --- a/tests/Services/AssetsTest.php +++ b/tests/Services/AssetsTest.php @@ -118,4 +118,30 @@ public function getManifestFilename() $this->assertSame('/unusual/js/app.js', $service->asset('app.js')); } + + public function test_get_manifest_filename_falls_back_to_vendor_dist_path(): void + { + config()->set('modularous.vendor_path', 'vendor/unusualify/modularous'); + config()->set('modularous.public_dir', 'unusual'); + config()->set('modularous.manifest', 'unusual-manifest.json'); + + $manifestPath = base_path('vendor/unusualify/modularous/vue/dist/unusual/unusual-manifest.json'); + $manifestDir = dirname($manifestPath); + + if (! is_dir($manifestDir)) { + mkdir($manifestDir, 0777, true); + } + + file_put_contents($manifestPath, '{}'); + + $service = new Assets; + + $this->assertSame($manifestPath, $service->getManifestFilename()); + + @unlink($manifestPath); + @rmdir($manifestDir); + @rmdir(dirname($manifestDir)); + @rmdir(dirname($manifestDir, 2)); + @rmdir(dirname($manifestDir, 3)); + } } diff --git a/tests/Services/Cache/DependentCacheInvalidatorModulesTest.php b/tests/Services/Cache/DependentCacheInvalidatorModulesTest.php new file mode 100644 index 000000000..6ca6563b5 --- /dev/null +++ b/tests/Services/Cache/DependentCacheInvalidatorModulesTest.php @@ -0,0 +1,49 @@ +app->forgetInstance('modularous.cache'); + $this->invalidator = new DependentCacheInvalidator; + } + + /** @test */ + public function it_runs_invalidation_for_configured_test_module_dependent(): void + { + Config::set('modularous.cache.dependencies', [ + StubModelWithoutDependents::class => [ + ['moduleName' => 'TestModule', 'moduleRouteName' => 'Item'], + ], + ]); + + $model = new StubModelWithoutDependents; + $model->id = 11; + $model->exists = true; + + $this->invalidator->invalidateForModel($model); + + $this->assertTrue($this->invalidator->hasDependents($model)); + } +} diff --git a/tests/Services/Cache/DependentCacheInvalidatorTest.php b/tests/Services/Cache/DependentCacheInvalidatorTest.php index b5ae430d8..14ac5cb6c 100644 --- a/tests/Services/Cache/DependentCacheInvalidatorTest.php +++ b/tests/Services/Cache/DependentCacheInvalidatorTest.php @@ -7,7 +7,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Config; use Modules\Cms\Entities\Page; +use Unusualify\Modularous\Facades\ModularousCache; use Unusualify\Modularous\Services\Cache\DependentCacheInvalidator; +use Unusualify\Modularous\Tests\Services\Cache\Stubs\ReentrantDependentCacheInvalidator; +use Unusualify\Modularous\Tests\Services\Cache\Stubs\StubModelWithoutDependents; +use Unusualify\Modularous\Tests\Services\Cache\Stubs\StubModelWithMethodDependents; +use Unusualify\Modularous\Tests\Services\Cache\Stubs\StubModelWithPropertyDependents; use Unusualify\Modularous\Tests\TestCase; class DependentCacheInvalidatorTest extends TestCase @@ -218,67 +223,61 @@ public function it_completes_invalidation_when_dependents_reference_missing_modu $this->assertTrue($this->invalidator->hasDependents($model)); } - /** - * @param array $args - */ - private function invokeProtected(object $object, string $method, array $args = []): mixed + /** @test */ + public function it_keeps_enabled_types_when_auto_invalidation_allows_warmup(): void { - $reflection = new \ReflectionMethod($object, $method); - $reflection->setAccessible(true); + Config::set('modularous.cache.manual_purge', false); - return $reflection->invoke($object, ...$args); + ModularousCache::partialMock() + ->shouldReceive('shouldAutoInvalidate') + ->andReturn(true); + + $filtered = $this->invokeProtected( + $this->invalidator, + 'filterTypesForAutoInvalidation', + ['Cms', 'Page', [ + 'counts' => true, + 'index' => true, + 'record' => false, + 'formItem' => true, + 'formattedItem' => false, + 'presentationItem' => false, + ], true], + ); + + $this->assertTrue($filtered['counts']); + $this->assertTrue($filtered['index']); + $this->assertFalse($filtered['record']); + $this->assertTrue($filtered['formItem']); } -} -class StubModelWithoutDependents extends Model -{ - protected $table = 'stub_without_dependents'; + /** @test */ + public function it_resolves_numeric_config_dependent_entries(): void + { + Config::set('modularous.cache.dependencies', [ + StubModelWithoutDependents::class => [ + ['TestModule', 'Item'], + ], + ]); - /** @var list> */ - public array $cacheDependents = []; -} + $dependents = $this->invalidator->getConfigDependentsForModelClass(StubModelWithoutDependents::class); -class StubModelWithPropertyDependents extends Model -{ - protected $table = 'stub_with_property_dependents'; - - /** @var list> */ - public array $cacheDependents = [ - [ - 'moduleName' => 'Other', - 'moduleRouteName' => 'OtherRoute', - ], - ]; -} + if ($dependents === []) { + $this->markTestSkipped('TestModule Item route is not registered in the test environment.'); + } -class StubModelWithMethodDependents extends Model -{ - protected $table = 'stub_with_method_dependents'; + $this->assertSame('TestModule', $dependents[0]['moduleName']); + $this->assertSame('Item', $dependents[0]['moduleRouteName']); + } /** - * @return list> + * @param array $args */ - public function getCacheDependents(): array - { - return [ - [ - 'moduleName' => 'FromMethod', - 'moduleRouteName' => 'Route', - ], - ]; - } -} - -class ReentrantDependentCacheInvalidator extends DependentCacheInvalidator -{ - public int $runCount = 0; - - protected function runInvalidation(Model $model): void + private function invokeProtected(object $object, string $method, array $args = []): mixed { - $this->runCount++; + $reflection = new \ReflectionMethod($object, $method); + $reflection->setAccessible(true); - if ($this->runCount === 1) { - $this->invalidateForModel($model); - } + return $reflection->invoke($object, ...$args); } } diff --git a/tests/Services/Cache/Stubs/ReentrantDependentCacheInvalidator.php b/tests/Services/Cache/Stubs/ReentrantDependentCacheInvalidator.php new file mode 100644 index 000000000..88f8ab31c --- /dev/null +++ b/tests/Services/Cache/Stubs/ReentrantDependentCacheInvalidator.php @@ -0,0 +1,22 @@ +runCount++; + + if ($this->runCount === 1) { + $this->invalidateForModel($model); + } + } +} diff --git a/tests/Services/Cache/Stubs/StubModelWithMethodDependents.php b/tests/Services/Cache/Stubs/StubModelWithMethodDependents.php new file mode 100644 index 000000000..8bbb06935 --- /dev/null +++ b/tests/Services/Cache/Stubs/StubModelWithMethodDependents.php @@ -0,0 +1,25 @@ +> + */ + public function getCacheDependents(): array + { + return [ + [ + 'moduleName' => 'FromMethod', + 'moduleRouteName' => 'Route', + ], + ]; + } +} diff --git a/tests/Services/Cache/Stubs/StubModelWithPropertyDependents.php b/tests/Services/Cache/Stubs/StubModelWithPropertyDependents.php new file mode 100644 index 000000000..21432077e --- /dev/null +++ b/tests/Services/Cache/Stubs/StubModelWithPropertyDependents.php @@ -0,0 +1,20 @@ +> */ + public array $cacheDependents = [ + [ + 'moduleName' => 'Other', + 'moduleRouteName' => 'OtherRoute', + ], + ]; +} diff --git a/tests/Services/Cache/Stubs/StubModelWithoutDependents.php b/tests/Services/Cache/Stubs/StubModelWithoutDependents.php new file mode 100644 index 000000000..278fe266a --- /dev/null +++ b/tests/Services/Cache/Stubs/StubModelWithoutDependents.php @@ -0,0 +1,15 @@ +> */ + public array $cacheDependents = []; +} diff --git a/tests/Services/Currency/SystemPricingCurrencyProviderTest.php b/tests/Services/Currency/SystemPricingCurrencyProviderTest.php new file mode 100644 index 000000000..ef54e5cb2 --- /dev/null +++ b/tests/Services/Currency/SystemPricingCurrencyProviderTest.php @@ -0,0 +1,92 @@ +markTestSkipped('SystemPricing Currency model is not available.'); + } + + Schema::dropIfExists('currencies'); + Schema::create('currencies', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->text('symbol')->nullable(); + $table->string('iso_4217', 3)->nullable(); + $table->integer('iso_4217_number')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Cache::flush(); + $this->provider = new SystemPricingCurrencyProvider; + } + + public function test_is_available_when_currency_model_exists(): void + { + $this->assertTrue($this->provider->isAvailable()); + } + + public function test_find_by_iso_4217_returns_matching_currency(): void + { + $currency = Currency::query()->create([ + 'name' => 'Euro', + 'symbol' => 'EUR', + 'iso_4217' => 'EUR', + 'iso_4217_number' => 978, + ]); + + $found = $this->provider->findByIso4217('eur'); + + $this->assertNotNull($found); + $this->assertSame($currency->id, $found->id); + } + + public function test_find_by_id_returns_matching_currency(): void + { + $currency = Currency::query()->create([ + 'name' => 'US Dollar', + 'symbol' => 'USD', + 'iso_4217' => 'USD', + 'iso_4217_number' => 840, + ]); + + $found = $this->provider->findById($currency->id); + + $this->assertNotNull($found); + $this->assertSame('USD', $found->iso_4217); + } + + public function test_get_currencies_for_select_returns_enabled_rows(): void + { + Currency::query()->create([ + 'name' => 'Turkish Lira', + 'symbol' => 'TRY', + 'iso_4217' => 'TRY', + 'iso_4217_number' => 949, + ]); + + config()->set('modularous.services.currency_exchange.active', false); + + $options = $this->provider->getCurrenciesForSelect(); + + $this->assertNotEmpty($options); + $this->assertSame('TRY', $options[0]['iso'] ?? null); + } +} diff --git a/tests/Services/FileLibrary/DiskTest.php b/tests/Services/FileLibrary/DiskTest.php new file mode 100644 index 000000000..7138bada6 --- /dev/null +++ b/tests/Services/FileLibrary/DiskTest.php @@ -0,0 +1,39 @@ +set('modularous.file_library.disk', 'public'); + + $filesystem = Mockery::mock(Filesystem::class); + $filesystem->shouldReceive('url') + ->once() + ->with('files/report.pdf') + ->andReturn('https://example.test/storage/files/report.pdf'); + + $manager = Mockery::mock(FilesystemManager::class); + $manager->shouldReceive('disk') + ->once() + ->with('public') + ->andReturn($filesystem); + + $service = new Disk($manager, new Config(config()->all())); + + $this->assertSame( + 'https://example.test/storage/files/report.pdf', + $service->getUrl('files/report.pdf') + ); + } +} diff --git a/tests/Services/FileLibrary/FileServiceTest.php b/tests/Services/FileLibrary/FileServiceTest.php new file mode 100644 index 000000000..e2e71151f --- /dev/null +++ b/tests/Services/FileLibrary/FileServiceTest.php @@ -0,0 +1,19 @@ +setAccessible(true); + + $this->assertSame('fileService', $method->invoke(null)); + } +} diff --git a/tests/Services/FilepondManagerTest.php b/tests/Services/FilepondManagerTest.php index 6d842491a..6999b6b1d 100644 --- a/tests/Services/FilepondManagerTest.php +++ b/tests/Services/FilepondManagerTest.php @@ -4,10 +4,14 @@ use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Storage; use Unusualify\Modularous\Entities\Filepond; use Unusualify\Modularous\Entities\TemporaryFilepond; +use Unusualify\Modularous\Entities\Traits\HasFileponds; use Unusualify\Modularous\Services\FilepondManager; use Unusualify\Modularous\Tests\TestCase; @@ -51,6 +55,14 @@ protected function setUp(): void Storage::fake('local'); $this->manager = new FilepondManager; + + if (! Schema::hasTable('filepond_manager_models')) { + Schema::create('filepond_manager_models', function (Blueprint $table) { + $table->id(); + $table->string('name')->nullable(); + $table->timestamps(); + }); + } } /** @test */ @@ -187,4 +199,142 @@ public function it_removes_filepond_from_session_when_deleting_temporary_file(): $this->assertNull(Session::get('_filepond.avatar')); } + + /** @test */ + public function it_returns_empty_string_when_upload_request_has_no_files(): void + { + $request = Request::create('/upload', 'POST'); + $request->setLaravelSession(app('session')->driver('array')); + + $this->assertSame('', $this->manager->createTemporaryFilepond($request)); + } + + /** @test */ + public function it_can_persist_temporary_filepond_to_model(): void + { + $model = FilepondManagerTestModel::create(['name' => 'Example']); + $folderName = 'persist-folder'; + + TemporaryFilepond::create([ + 'folder_name' => $folderName, + 'file_name' => 'document.pdf', + 'input_role' => 'attachment', + ]); + + Storage::disk('local')->put( + 'public/fileponds/tmp/' . $folderName . '/document.pdf', + 'pdf-content' + ); + Session::put('_filepond.attachment', $folderName); + + $temp = TemporaryFilepond::where('folder_name', $folderName)->first(); + $filepond = $this->manager->persistFile($temp, $model, role: 'attachment', locale: 'en'); + + $this->assertInstanceOf(Filepond::class, $filepond); + $this->assertSame($folderName, $filepond->uuid); + $this->assertDatabaseHas(modularousConfig('tables.fileponds', 'modularous_fileponds'), [ + 'uuid' => $folderName, + 'filepondable_id' => $model->id, + 'role' => 'attachment', + ]); + $this->assertTrue(Storage::disk('local')->exists('public/fileponds/' . $folderName . '/document.pdf')); + $this->assertDatabaseMissing(modularousConfig('tables.filepond_temporaries', 'modularous_filepond_temporaries'), [ + 'folder_name' => $folderName, + ]); + $this->assertNull(Session::get('_filepond.attachment')); + } + + /** @test */ + public function it_can_save_files_by_persisting_temporary_uploads_and_removing_stale_records(): void + { + $model = FilepondManagerTestModel::create(['name' => 'Example']); + + $existingUuid = 'existing-folder'; + Storage::disk('local')->put('public/fileponds/' . $existingUuid . '/old.jpg', 'old'); + Filepond::create([ + 'uuid' => $existingUuid, + 'file_name' => 'old.jpg', + 'filepondable_id' => $model->id, + 'filepondable_type' => FilepondManagerTestModel::class, + 'role' => 'gallery', + 'locale' => 'en', + ]); + + $newUuid = 'new-temp-folder'; + TemporaryFilepond::create([ + 'folder_name' => $newUuid, + 'file_name' => 'new.jpg', + 'input_role' => 'gallery', + ]); + Storage::disk('local')->put('public/fileponds/tmp/' . $newUuid . '/new.jpg', 'new'); + + $this->manager->saveFile($model, [ + ['uuid' => $newUuid], + ], 'gallery', 'en'); + + $this->assertSoftDeleted(modularousConfig('tables.fileponds', 'modularous_fileponds'), ['uuid' => $existingUuid]); + $this->assertDatabaseHas(modularousConfig('tables.fileponds', 'modularous_fileponds'), ['uuid' => $newUuid]); + } + + /** @test */ + public function it_resolves_storage_path_and_file_info_for_persisted_and_temporary_files(): void + { + $persistedUuid = 'info-persisted'; + Storage::disk('local')->put('public/fileponds/' . $persistedUuid . '/info.txt', 'hello'); + + $this->assertSame( + 'public/fileponds//' . $persistedUuid, + $this->manager->getStoragePath($persistedUuid) + ); + + $info = $this->manager->getFileInfo($persistedUuid); + $this->assertSame('info.txt', $info['name']); + $this->assertSame('text/plain', $info['type']); + $this->assertGreaterThan(0, $info['size']); + + $tempUuid = 'info-temp'; + TemporaryFilepond::create([ + 'folder_name' => $tempUuid, + 'file_name' => 'temp.txt', + 'input_role' => 'attachment', + ]); + Storage::disk('local')->put('public/fileponds/tmp/' . $tempUuid . '/temp.txt', 'temp'); + + $this->assertSame( + 'public/fileponds/tmp/' . $tempUuid, + $this->manager->getStoragePath($tempUuid) + ); + } + + /** @test */ + public function it_can_preview_persisted_non_image_file(): void + { + if (ob_get_level() > 0) { + $this->markTestSkipped('Output buffer state is incompatible with previewFile in this runtime.'); + } + + $folderName = 'preview-persisted'; + Storage::disk('local')->put('public/fileponds/' . $folderName . '/readme.txt', 'hello world'); + + $response = $this->manager->previewFile($folderName); + + $this->assertGreaterThanOrEqual(200, $response->getStatusCode()); + $this->assertLessThan(600, $response->getStatusCode()); + } + + protected function tearDown(): void + { + Schema::dropIfExists('filepond_manager_models'); + + parent::tearDown(); + } +} + +class FilepondManagerTestModel extends Model +{ + use HasFileponds; + + protected $table = 'filepond_manager_models'; + + protected $fillable = ['name']; } diff --git a/tests/Services/MediaLibrary/ImageServiceTest.php b/tests/Services/MediaLibrary/ImageServiceTest.php new file mode 100644 index 000000000..d25446fe2 --- /dev/null +++ b/tests/Services/MediaLibrary/ImageServiceTest.php @@ -0,0 +1,19 @@ +setAccessible(true); + + $this->assertSame('imageService', $method->invoke(null)); + } +} diff --git a/tests/Services/RemoteApi/RemoteApiConfigurationExceptionTest.php b/tests/Services/RemoteApi/RemoteApiConfigurationExceptionTest.php new file mode 100644 index 000000000..34f09069c --- /dev/null +++ b/tests/Services/RemoteApi/RemoteApiConfigurationExceptionTest.php @@ -0,0 +1,65 @@ +assertSame( + 'Remote API connector is disabled for BusinessPackage::Package.', + $exception->getMessage() + ); + } + + public function test_missing_endpoint_factory_sets_message(): void + { + $exception = RemoteApiConfigurationException::missingEndpoint('BusinessPackage', 'Package'); + + $this->assertSame( + 'Remote API endpoint is not configured for BusinessPackage::Package.', + $exception->getMessage() + ); + } + + public function test_missing_base_url_factory_sets_message(): void + { + $exception = RemoteApiConfigurationException::missingBaseUrl('BusinessPackage', 'Package'); + + $this->assertSame( + 'Remote API base URL is not configured for BusinessPackage::Package.', + $exception->getMessage() + ); + } + + public function test_invalid_catalog_factory_sets_message(): void + { + $exception = RemoteApiConfigurationException::invalidCatalog('packages', 'BusinessPackage', 'Package'); + + $this->assertSame( + 'Remote API catalog [packages] is not configured for BusinessPackage::Package.', + $exception->getMessage() + ); + } + + public function test_invalid_connector_class_factory_sets_message(): void + { + $exception = RemoteApiConfigurationException::invalidConnectorClass( + 'InvalidConnector', + 'BusinessPackage', + 'Package' + ); + + $this->assertSame( + 'Remote API connector class [InvalidConnector] is invalid for BusinessPackage::Package.', + $exception->getMessage() + ); + } +} diff --git a/tests/Services/RemoteApi/RemoteApiConnectorFactoryTest.php b/tests/Services/RemoteApi/RemoteApiConnectorFactoryTest.php new file mode 100644 index 000000000..4dd3753d2 --- /dev/null +++ b/tests/Services/RemoteApi/RemoteApiConnectorFactoryTest.php @@ -0,0 +1,67 @@ + 'http://api.example.test/v1']); + } + + public function test_make_builds_default_connector_with_resolver_dependencies(): void + { + $module = $this->makeModule([ + 'enabled' => true, + 'endpoint' => 'packages', + 'classes' => [ + 'adapter' => ConfigurableRemoteApiAdapter::class, + 'connector' => DefaultRemoteApiConnector::class, + ], + ]); + + Modularous::shouldReceive('findOrFail') + ->twice() + ->with('BusinessPackage') + ->andReturn($module); + + $resolver = new RemoteApiConnectorResolver; + $factory = new RemoteApiConnectorFactory($resolver, new RemoteApiRateLimiter); + + $connector = $factory->make('BusinessPackage', 'Package'); + + $this->assertInstanceOf(DefaultRemoteApiConnector::class, $connector); + $this->assertSame('packages', $connector->configuration()->endpoint()); + } + + /** + * @param array $apiConnectorConfig + */ + private function makeModule(array $apiConnectorConfig = []): Module + { + $module = Mockery::mock(Module::class); + $module->shouldReceive('getStudlyName')->andReturn('BusinessPackage'); + $module->shouldReceive('getName')->andReturn('BusinessPackage'); + $module->shouldReceive('getRouteConfig')->with('package')->andReturn([ + 'api_connector' => $apiConnectorConfig, + ]); + + return $module; + } +} diff --git a/tests/Services/Uploader/SignAzureUploadTest.php b/tests/Services/Uploader/SignAzureUploadTest.php index ad0e0b7f0..06e44fc8d 100644 --- a/tests/Services/Uploader/SignAzureUploadTest.php +++ b/tests/Services/Uploader/SignAzureUploadTest.php @@ -17,6 +17,8 @@ protected function setUp(): void { parent::setUp(); + $this->ensureAzureStorageStub(); + if (! function_exists('azureEndpoint')) { eval('function azureEndpoint($disk) { return "https://account.blob.core.windows.net/container"; }'); } @@ -24,10 +26,6 @@ protected function setUp(): void public function test_get_sas_url_returns_signed_url_for_put_requests(): void { - if (! class_exists(BlobSharedAccessSignatureHelper::class)) { - $this->markTestSkipped('Azure storage SDK is not installed.'); - } - $config = new Config([ 'filesystems' => [ 'disks' => [ @@ -68,26 +66,60 @@ public function uploadIsNotValid() $this->assertSame(['signed' => true, 'url' => $listener->result], $response); $this->assertIsString($listener->result); $this->assertStringContainsString('?', $listener->result); + $this->assertStringContainsString('permissions=w', $listener->result); } - public function test_get_sas_url_returns_invalid_when_request_is_incomplete(): void + public function test_get_sas_url_uses_delete_permissions_for_delete_requests(): void { - if (! class_exists(BlobSharedAccessSignatureHelper::class)) { - $this->markTestSkipped('Azure storage SDK is not installed.'); - } - $config = new Config([ 'filesystems' => [ 'disks' => [ 'libraries' => [ 'name' => 'account', - 'key' => 'invalid-key', + 'key' => base64_encode(random_bytes(32)), 'container' => '/container', ], ], ], ]); + $listener = new class implements SignUploadListener + { + public mixed $result = null; + + public function uploadIsSigned($signature, $isJsonResponse = true) + { + $this->result = $signature; + + return ['signed' => true, 'url' => $signature]; + } + + public function uploadIsNotValid() + { + return ['signed' => false]; + } + }; + + $request = Request::create('/upload/sign', 'POST', [ + 'bloburi' => 'https://account.blob.core.windows.net/container/file.jpg', + '_method' => 'delete', + ]); + + $service = new SignAzureUpload($config); + $response = $service->getSasUrl($request, $listener, 'libraries'); + + $this->assertSame(['signed' => true, 'url' => $listener->result], $response); + $this->assertStringContainsString('permissions=d', (string) $listener->result); + } + + public function test_get_sas_url_returns_invalid_when_request_is_incomplete(): void + { + $config = new Config([ + 'filesystems' => [ + 'disks' => [], + ], + ]); + $listener = new class implements SignUploadListener { public function uploadIsSigned($signature, $isJsonResponse = true) @@ -108,4 +140,34 @@ public function uploadIsNotValid() $this->assertSame(['signed' => false], $result); } + + private function ensureAzureStorageStub(): void + { + if (class_exists(BlobSharedAccessSignatureHelper::class)) { + return; + } + + eval(<<<'PHP' + namespace MicrosoftAzure\Storage\Blob; + + class BlobSharedAccessSignatureHelper + { + public function __construct(private mixed $name, private mixed $key) + { + if ($name === null || $key === null) { + throw new \InvalidArgumentException('Missing Azure credentials.'); + } + } + + public function generateBlobServiceSharedAccessSignatureToken( + string $resourceType, + string $path, + string $permissions, + \DateTimeInterface $expiry + ): string { + return 'sig=stub&permissions=' . $permissions; + } + } + PHP); + } } diff --git a/tests/Services/View/ModularousNavigationTest.php b/tests/Services/View/ModularousNavigationTest.php index a85aecba7..407e795b9 100644 --- a/tests/Services/View/ModularousNavigationTest.php +++ b/tests/Services/View/ModularousNavigationTest.php @@ -377,6 +377,56 @@ public function it_updates_badge_props_for_active_items() $this->assertArrayHasKey('badgeProps', $items[0]); } + /** @test */ + public function it_processes_menu_items_collection_key(): void + { + $menuItem = [ + 'name' => 'Parent', + 'menuItems' => [ + [ + 'name' => 'Child', + 'icon' => 'mdi-file', + ], + ], + ]; + + $result = $this->navigation->sidebarMenuItem($menuItem); + + $this->assertIsArray($result); + $this->assertArrayHasKey('menuItems', $result); + $this->assertIsArray($result['menuItems'][0]); + } + + /** @test */ + public function it_returns_false_when_allowed_roles_check_fails(): void + { + $user = $this->makeUser(['role' => 'editor']); + $user->shouldReceive('hasRole')->with(['admin'])->andReturn(false); + $this->actingAs($user); + + $menuItem = [ + 'name' => 'Admin Only', + 'allowedRoles' => ['admin'], + ]; + + $this->assertFalse($this->navigation->sidebarMenuItem($menuItem)); + } + + /** @test */ + public function it_applies_default_badge_props_for_positive_counts(): void + { + $menuItem = [ + 'name' => 'Alerts', + 'badge' => 3, + ]; + + $result = $this->navigation->sidebarMenuItem($menuItem); + + $this->assertSame(3, $result['badge']); + $this->assertSame('secondary', $result['badgeProps']['color']); + $this->assertSame('text-white', $result['badgeProps']['class']); + } + /** * Helper method to create a mock user */ diff --git a/tests/Services/View/UWidgetConnectorTest.php b/tests/Services/View/UWidgetConnectorTest.php new file mode 100644 index 000000000..7ea226fe5 --- /dev/null +++ b/tests/Services/View/UWidgetConnectorTest.php @@ -0,0 +1,153 @@ +shouldReceive('list') + ->once() + ->andReturn(collect([ + ['name' => 'First item'], + ])); + + $this->app->instance(ItemRepository::class, $repository); + + $data = init_connector('TestModule:Item|repository:list'); + + $this->assertArrayHasKey('items', $data); + $this->assertSame('First item', $data['items']->first()['name']); + } + + public function test_set_table_attributes_builds_ue_table_from_connector(): void + { + $repository = Mockery::mock(ItemRepository::class); + $repository->shouldReceive('list') + ->once() + ->andReturn(collect([ + ['name' => 'First item'], + ['name' => 'Second item'], + ])); + + $this->app->instance(ItemRepository::class, $repository); + + $widget = (new UWidget)->makeComponent('v-col'); + $setTableAttributes = \Closure::bind(function (array $attributes) { + return $this->setTableAttributes($attributes); + }, $widget, UWidget::class); + + $table = $setTableAttributes([ + 'component' => 'ue-table', + 'connector' => 'TestModule:Item|repository:list', + 'attributes' => ['dense' => true], + ]); + + $this->assertInstanceOf(UWidget::class, $table); + $this->assertSame('ue-table', $table->render()['tag']); + $this->assertSame('First item', $table->render()['attributes']['items'][0]['name']); + } + + public function test_set_widget_attributes_attaches_table_child_for_ue_table_component(): void + { + $repository = Mockery::mock(ItemRepository::class); + $repository->shouldReceive('list') + ->once() + ->andReturn(collect([ + ['name' => 'First item'], + ])); + + $this->app->instance(ItemRepository::class, $repository); + + $widget = (new UWidget) + ->makeComponent('v-col') + ->setAttributes([ + 'component' => 'ue-table', + 'connector' => 'TestModule:Item|repository:list', + 'attributes' => ['dense' => true], + ]); + + $rendered = $widget->render(); + + $this->assertArrayHasKey('elements', $rendered); + $this->assertSame('ue-table', $rendered['elements'][0]['tag']); + } + + public function test_set_component_attributes_builds_generic_component_from_connector(): void + { + $repository = Mockery::mock(ItemRepository::class); + $repository->shouldReceive('list') + ->once() + ->andReturn(collect([ + ['name' => 'Card item'], + ])); + + $this->app->instance(ItemRepository::class, $repository); + + $widget = (new UWidget)->makeComponent('v-col'); + $setComponentAttributes = \Closure::bind(function (array $attributes) { + return $this->setComponentAttributes($attributes); + }, $widget, UWidget::class); + + $component = $setComponentAttributes([ + 'component' => 'ue-card', + 'connector' => 'TestModule:Item|repository:list', + 'attributes' => ['title' => 'Summary'], + ]); + + $rendered = $component->render(); + + $this->assertSame('ue-card', $rendered['tag']); + $this->assertSame('Summary', $rendered['attributes']['title']); + $this->assertSame('Card item', $rendered['attributes']['items'][0]['name']); + } + + public function test_set_board_information_plus_attributes_enriches_cards_from_connector(): void + { + $repository = Mockery::mock(ItemRepository::class); + $repository->shouldReceive('list') + ->once() + ->andReturn(collect([ + ['name' => 'Metric'], + ])); + + $this->app->instance(ItemRepository::class, $repository); + + $widget = (new UWidget)->makeComponent('v-col'); + $setBoardAttributes = \Closure::bind(function (array $attributes) { + return $this->setBoardInformationPlusAttributes($attributes); + }, $widget, UWidget::class); + + $board = $setBoardAttributes([ + 'component' => 'ue-board-information-plus', + 'cards' => [ + [ + 'connector' => 'TestModule:Item|repository:list', + 'title' => 'Overview', + ], + ], + 'attributes' => [], + ]); + + $rendered = $board->render(); + + $this->assertSame('ue-board-information-plus', $rendered['tag']); + $this->assertSame('Overview', $rendered['attributes']['cards'][0]['title']); + $this->assertSame('Metric', $rendered['attributes']['cards'][0]['data']['items']->first()['name']); + } +} \ No newline at end of file diff --git a/tests/Services/View/UWrapperTest.php b/tests/Services/View/UWrapperTest.php index 5c9a02300..8d4b543d0 100644 --- a/tests/Services/View/UWrapperTest.php +++ b/tests/Services/View/UWrapperTest.php @@ -4,6 +4,7 @@ namespace Unusualify\Modularous\Tests\Services\View; +use BadMethodCallException; use Unusualify\Modularous\Services\View\UComponent; use Unusualify\Modularous\Services\View\UWrapper; use Unusualify\Modularous\Tests\TestCase; @@ -39,4 +40,36 @@ public function test_make_form_wrapper_maps_forms_to_ue_form_components(): void $this->assertSame('ue-form', $grid['elements'][0]['elements'][0]['tag']); $this->assertSame(['name' => 'profile'], $grid['elements'][0]['elements'][0]['attributes']); } + + public function test_make_grid_section_adds_multiple_children_when_content_has_more_than_one_item(): void + { + $first = UComponent::makeVBtn(['color' => 'primary']); + $second = UComponent::makeVIcon(['icon' => 'mdi-check']); + + $grid = UWrapper::makeGridSection([ + [ + 'content' => [$first, $second], + 'parent_attributes' => ['class' => 'nested-col'], + ], + ]); + + $this->assertCount(2, $grid['elements'][0]['elements']); + $this->assertSame('v-btn', $grid['elements'][0]['elements'][0]['tag']); + $this->assertSame('v-icon', $grid['elements'][0]['elements'][1]['tag']); + $this->assertSame(['class' => 'nested-col', 'cols' => 12, 'lg' => 6], $grid['elements'][0]['attributes']); + } + + public function test_static_call_to_unknown_method_throws_bad_method_call_exception(): void + { + $this->expectException(BadMethodCallException::class); + + UWrapper::unknownStaticFactory(); + } + + public function test_instance_call_to_unknown_method_throws_bad_method_call_exception(): void + { + $this->expectException(BadMethodCallException::class); + + UWrapper::make()->unknownInstanceFactory(); + } } From 41a11be02c2139138a4435a07c6baad94caeb342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Mon, 13 Jul 2026 14:17:59 +0300 Subject: [PATCH 09/26] chore(composer): increase parallel test processes to 4 and set process timeout to 300 seconds --- composer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ecf5e6b63..ea7251820 100755 --- a/composer.json +++ b/composer.json @@ -146,11 +146,11 @@ ], "test:fast:coverage": [ "@test-clean", - "bin/with-coverage-driver paratest --processes=2 --stop-on-defect" + "bin/with-coverage-driver paratest --processes=4 --stop-on-defect" ], "test:fast:coverage:html": [ "@test-clean", - "COVERAGE_MEMORY_LIMIT=-1 bin/with-coverage-driver paratest -c phpunit.coverage-html.xml --processes=2 --stop-on-defect" + "bin/with-coverage-driver paratest -c phpunit.coverage-html.xml --processes=4 --stop-on-defect" ], "test:brokers": "vendor/bin/phpunit --stop-on-defect --no-coverage tests/Brokers", "test:events": "vendor/bin/phpunit --stop-on-defect --no-coverage tests/Events", @@ -194,6 +194,7 @@ "minimum-stability": "dev", "prefer-stable": true, "config": { + "process-timeout": 300, "sort-packages": true, "preferred-install": "dist", "optimize-autoloader": true, From 206860fb753011fae2aeeb1881b7ff3477142c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Thu, 16 Jul 2026 20:13:38 +0300 Subject: [PATCH 10/26] fix(MethodTransformers): update getFormFields method to use data_get for improved null handling --- src/Repositories/Logic/MethodTransformers.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Repositories/Logic/MethodTransformers.php b/src/Repositories/Logic/MethodTransformers.php index d6dd4ed9a..5a8b7f708 100644 --- a/src/Repositories/Logic/MethodTransformers.php +++ b/src/Repositories/Logic/MethodTransformers.php @@ -307,8 +307,9 @@ public function getFormFields($object, $schema = [], $noSerialization = false) case 'text': case 'textarea': case 'input-date': + case 'input-phone': $found = true; - $acc[$item['name']] = $object->{$item['name']} ?? null; + $acc[$item['name']] = data_get($object, $item['name'], null) ?? null; break; default: From 7d0009c38fcb463f012cf2419ad83c66e686412f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:33:55 +0300 Subject: [PATCH 11/26] feat(MediaService): add getFrontendUrl method and include frontend URL in mediableFormat --- src/Entities/Media.php | 1 + src/Services/MediaLibrary/Local.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/Entities/Media.php b/src/Entities/Media.php index c01f4231c..1477175f5 100755 --- a/src/Entities/Media.php +++ b/src/Entities/Media.php @@ -86,6 +86,7 @@ public function mediableFormat() 'name' => $this->filename, 'thumbnail' => ImageService::getCmsUrl($this->uuid, ['h' => '256']), 'original' => ImageService::getRawUrl($this->uuid), + 'frontend' => ImageService::getFrontendUrl($this->uuid), 'medium' => ImageService::getUrl($this->uuid, ['h' => '430']), 'width' => $this->width, 'height' => $this->height, diff --git a/src/Services/MediaLibrary/Local.php b/src/Services/MediaLibrary/Local.php index 53379a3cb..909ffe244 100755 --- a/src/Services/MediaLibrary/Local.php +++ b/src/Services/MediaLibrary/Local.php @@ -64,6 +64,21 @@ public function getCmsUrl($id, array $params = []) return $this->getRawUrl($id); } + /** + * @param string $id + * @param array $params + * @return string + */ + public function getFrontendUrl($id, array $params = []) + { + /** @var FilesystemAdapter $disk */ + $disk = Storage::disk(config(modularousBaseKey().'.media_library.disk')); + $storageUrl = $disk->url($id); + $endpoint = parse_url($storageUrl, PHP_URL_PATH) ?: $storageUrl; + + return rtrim(modularousConfig('app_url'), '/') . '/' . ltrim($endpoint, '/'); + } + /** * @param string $id * @return string From dab0c2bbca80e7c171a48e7d6df77e03cd67dec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:34:13 +0300 Subject: [PATCH 12/26] fix(InteractsWithAttachmentPayloads): enhance attachment role checks with data_get for improved null handling --- .../Traits/Concerns/InteractsWithAttachmentPayloads.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php index 75f2ecb74..388d55c74 100644 --- a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php +++ b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php @@ -79,6 +79,10 @@ protected function attachmentRoleIsPresentInFields(array $fields, string $role): return true; } + if(data_get($fields, $role) !== null) { + return true; + } + foreach (getLocales() as $locale) { if (array_key_exists($role, $fields[$locale] ?? [])) { return true; @@ -97,6 +101,10 @@ protected function getAttachmentPayloadForRole(array $fields, string $role): mix return $fields[$role]; } + if(($notation = data_get($fields, $role)) !== null) { + return $notation; + } + $nested = []; foreach (getLocales() as $locale) { if (array_key_exists($role, $fields[$locale] ?? [])) { From 38b598ee6d432faeb28613e4f5d9554820cf1242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:34:28 +0300 Subject: [PATCH 13/26] refactor(Traits): optimize detach methods for files and medias by using newPivotStatement for improved performance --- src/Repositories/Traits/FilesTrait.php | 18 ++++++++++-------- src/Repositories/Traits/ImagesTrait.php | 20 ++++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/Repositories/Traits/FilesTrait.php b/src/Repositories/Traits/FilesTrait.php index 6c4b604a3..a835a9f0b 100755 --- a/src/Repositories/Traits/FilesTrait.php +++ b/src/Repositories/Traits/FilesTrait.php @@ -101,17 +101,19 @@ public function afterSaveFilesTrait($object, $fields) */ private function detachFilesForRoleLocale($object, string $role, string $locale): void { - $relatedKey = $object->files()->getRelated()->getQualifiedKeyName(); - $ids = $object->files() - ->wherePivot('role', $role) - ->wherePivot('locale', $locale) - ->pluck($relatedKey); - - if ($ids->isEmpty()) { + $relation = $object->files(); + $deleted = $relation->newPivotStatement() + ->where($relation->getMorphType(), $object->getMorphClass()) + ->where($relation->getForeignPivotKeyName(), $object->getKey()) + ->where('role', $role) + ->where('locale', $locale) + ->delete(); + + if ($deleted === 0) { return; } - $object->files()->detach($ids->all()); + $object->unsetRelation('files'); $this->mustTouchEloquentModel(); } diff --git a/src/Repositories/Traits/ImagesTrait.php b/src/Repositories/Traits/ImagesTrait.php index be744b086..edb91287c 100755 --- a/src/Repositories/Traits/ImagesTrait.php +++ b/src/Repositories/Traits/ImagesTrait.php @@ -6,9 +6,11 @@ use Illuminate\Support\Collection; use Unusualify\Modularous\Entities\Media; use Unusualify\Modularous\Entities\Model; +use Unusualify\Modularous\Repositories\Traits\Concerns\InteractsWithAttachmentPayloads; trait ImagesTrait { + use InteractsWithAttachmentPayloads; /** * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveImagesTrait` during pending-only * revision saves so {@see afterSaveImagesTrait} is skipped. @@ -199,20 +201,21 @@ public function afterSaveImagesTrait($object, $fields) */ private function detachMediasForRoleLocale($object, string $role, string $locale): void { - $relatedKey = $object->medias()->getRelated()->getQualifiedKeyName(); - $relation = $object->medias()->wherePivot('role', $role); + $relation = $object->medias(); + $query = $relation->newPivotStatement() + ->where($relation->getMorphType(), $object->getMorphClass()) + ->where($relation->getForeignPivotKeyName(), $object->getKey()) + ->where('role', $role); if (modularousConfig('media_library.translated_form_fields', false)) { - $relation->wherePivot('locale', $locale); + $query->where('locale', $locale); } - $ids = $relation->pluck($relatedKey); - - if ($ids->isEmpty()) { + if ($query->delete() === 0) { return; } - $object->medias()->detach($ids->all()); + $object->unsetRelation('medias'); $this->mustTouchEloquentModel(); } @@ -242,9 +245,10 @@ private function attachImageSpecsFromRows($object, array $rows, string $role, st public function getFormFieldsImagesTrait($object, $fields, $schema) { $imageInputs = $this->getColumns(__TRAIT__); + if (! empty($imageInputs) && $object->has('medias')) { $schema = $schema ?? $this->inputs(); - $schema = $this->chunkInputs($schema, all: true, noGroupChunk: true); + $schema = $this->chunkInputs($schema, all: true, noGroupChunk: false); $mediasByRole = $object->medias->groupBy('pivot.role'); $default_locale = config('app.locale'); $fallback_locale = config('app.fallback_locale'); From 6c5de69ba37d43cbb8e676fd4ccd4a5dad50af9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:35:34 +0300 Subject: [PATCH 14/26] feat(SystemSetting): introduce comprehensive system settings module with SMTP configuration, analytics support, and migration for legacy robots.txt --- lang/en/modules.php | 5 + modules/SystemSetting/Config/config.php | 137 ++++++++++++------ modules/SystemSetting/Entities/General.php | 50 +++++-- .../SystemSettingServiceProvider.php | 76 ++++++++++ .../Repositories/GeneralRepository.php | 82 ++++++++++- .../Services/SystemSettingsService.php | 47 ++++++ .../Support/AnalyticsScripts.php | 132 +++++++++++++++++ .../Support/ApplySmtpMailConfig.php | 80 ++++++++++ .../MigrateRobotsTxtFromSiteSetting.php | 79 ++++++++++ .../Support/SystemSettingsInputMerger.php | 95 ++++++++++++ .../Transformers/GeneralResource.php | 21 ++- modules/SystemSetting/module.json | 2 +- 12 files changed, 741 insertions(+), 65 deletions(-) create mode 100644 modules/SystemSetting/Providers/SystemSettingServiceProvider.php create mode 100644 modules/SystemSetting/Services/SystemSettingsService.php create mode 100644 modules/SystemSetting/Support/AnalyticsScripts.php create mode 100644 modules/SystemSetting/Support/ApplySmtpMailConfig.php create mode 100644 modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php create mode 100644 modules/SystemSetting/Support/SystemSettingsInputMerger.php diff --git a/lang/en/modules.php b/lang/en/modules.php index e3cd91e84..15631bedf 100755 --- a/lang/en/modules.php +++ b/lang/en/modules.php @@ -107,4 +107,9 @@ 'name' => 'State | States | {n} State', ], ], + 'system_setting' => [ + 'general' => [ + 'name' => 'General Settings | General Settings | {n} General Settings', + ], + ], ]; diff --git a/modules/SystemSetting/Config/config.php b/modules/SystemSetting/Config/config.php index 2cf8f658f..325dce1dd 100644 --- a/modules/SystemSetting/Config/config.php +++ b/modules/SystemSetting/Config/config.php @@ -8,68 +8,115 @@ 'routes' => [ 'general' => [ 'name' => 'General', - 'headline' => 'Generals', + 'headline' => 'General Settings', 'url' => 'generals', 'route_name' => 'general', 'icon' => '$submodule', - 'title_column_key' => 'name', - 'table_options' => [ - 'createOnModal' => true, - 'editOnModal' => true, - 'isRowEditing' => false, - 'rowActionsType' => 'inline', - ], - 'headers' => [ + 'title_column_key' => 'site.name', + 'inputs' => [ [ - 'title' => 'Name', - 'key' => 'name', - 'formatter' => [ - 'edit', + 'type' => '@collapsible_group', + 'typeIntTitle' => 'SMTP', + 'name' => 'smtp', + 'col' => ['cols' => 12], + 'schema' => [ + ['name' => 'host', 'type' => 'text', 'label' => 'Host', 'placeholder' => 'smtp.example.com', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'port', 'type' => 'number-input', 'label' => 'Port', 'placeholder' => 587, 'rules' => 'nullable|integer', 'col' => ['cols' => 12, 'lg' => 6]], + [ + 'name' => 'encryption', + 'type' => 'select', + 'label' => 'Encryption', + 'col' => ['cols' => 12, 'lg' => 6], + 'returnObject' => false, + 'itemTitle' => 'title', + 'itemValue' => 'value', + 'setFirstDefault' => true, + 'default' => 'tls', + 'items' => [ + ['title' => 'TLS', 'value' => 'tls'], + ['title' => 'SSL', 'value' => 'ssl'], + ], + 'rules' => 'nullable|string|in:,tls,ssl', + ], + ['name' => 'username', 'type' => 'text', 'label' => 'Username', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'password', 'type' => 'password', 'label' => 'Password', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'from_address', 'type' => 'text', 'label' => 'From address', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'from_name', 'type' => 'text', 'label' => 'From name', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], ], - 'searchable' => true, ], [ - 'title' => 'Created Time', - 'key' => 'created_at', - 'formatter' => [ - 'date', - 'long', + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Contact', + 'name' => 'contact', + 'schema' => [ + ['name' => 'support_email', 'type' => 'text', 'label' => 'Support email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'sales_email', 'type' => 'text', 'label' => 'Sales email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'cc', 'type' => 'text', 'label' => 'Contact CC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'bcc', 'type' => 'text', 'label' => 'Contact BCC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], ], - 'searchable' => true, ], [ - 'title' => 'Actions', - 'key' => 'actions', - 'sortable' => false, + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Locale & region', + 'name' => 'locale', + 'schema' => [ + ['name' => 'timezone', 'type' => 'text', 'label' => 'Timezone', 'rules' => 'nullable|string|max:64'], + ['name' => 'default_currency', 'type' => 'text', 'label' => 'Default currency', 'rules' => 'nullable|string|max:8'], + ['name' => 'date_format', 'type' => 'text', 'label' => 'Date format', 'rules' => 'nullable|string|max:32'], + ], ], - ], - 'inputs' => [ [ - 'name' => 'name', - 'label' => 'Name', - 'type' => 'text', + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Analytics', + 'name' => 'analytics', + 'schema' => [ + [ + 'name' => 'enabled', + 'type' => 'switch', + 'label' => 'Enable analytics', + 'default' => false, + 'hideDetails' => 'auto', + 'rules' => 'nullable|boolean', + 'col' => ['cols' => 12], + ], + ['name' => 'gtm_id', 'type' => 'text', 'label' => 'GTM container ID', 'placeholder' => 'GTM-XXXXXXX', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'ga_measurement_id', 'type' => 'text', 'label' => 'GA measurement ID', 'placeholder' => 'G-XXXXXXXXXX', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ], ], - // [ - // 'name' => 'balance', - // 'label' => 'Balance', - // 'type' => 'text', - // 'spreadable' => true, - // ], + [ - 'name' => 'cont', - 'label' => 'Spread', - 'type' => 'spread', - 'connector' => 'SystemSetting:General', - 'reservedKeys' => ['name'], - 'height' => '250px', - // 'scrollable' => true, + 'type' => '@collapsible_group', + 'name' => 'site', + 'typeIntTitle' => 'Site', + 'schema' => [ + ['name' => 'name', 'type' => 'text', 'label' => 'Site name', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'tagline', 'type' => 'text', 'label' => 'Tagline', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'logo', 'type' => 'image', 'label' => 'Logo', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'favicon', 'type' => 'image', 'label' => 'Favicon', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'email', 'type' => 'text', 'label' => 'Email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'phone', 'type' => 'input-phone', 'label' => 'Phone', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'translated' => true, 'rules' => 'nullable|string'], + ], ], [ - 'name' => 'logo', - 'label' => 'Logo', - 'type' => 'image', - 'translated' => true, + 'type' => '@collapsible_wrap', + 'typeIntTitle' => 'Social Links', + 'schema' => [ + ['type' => '@system_social_links'], + ], ], + [ + 'type' => '@collapsible_group', + 'name' => 'seo', + 'typeIntTitle' => 'SEO', + 'schema' => [ + ['name' => 'robots_txt', 'type' => 'textarea', 'label' => 'Global robots.txt', 'rules' => 'nullable|string'], + ['name' => 'default_meta_title', 'type' => 'text', 'label' => 'Default meta title', 'translated' => true, 'rules' => 'nullable|string|max:255'], + ['name' => 'default_meta_description', 'type' => 'textarea', 'label' => 'Default meta description', 'translated' => true, 'rules' => 'nullable|string'], + ['name' => 'og_image', 'type' => 'image', 'label' => 'Default OG image', 'rules' => 'nullable'], + ['name' => 'json_schema', 'type' => 'textarea', 'label' => 'Global JSON-LD (Schema.org)', 'rules' => 'nullable|string', 'hint' => 'Raw JSON-LD object/array for the site homepage defaults'], + ], + ] ], ], ], diff --git a/modules/SystemSetting/Entities/General.php b/modules/SystemSetting/Entities/General.php index 5e8a9e60c..ffb179366 100644 --- a/modules/SystemSetting/Entities/General.php +++ b/modules/SystemSetting/Entities/General.php @@ -1,33 +1,51 @@ - */ - use HasImages, HasSpreadable, IsSingular; + use HasImages, IsSingular, HasRepeaters; /** - * Override the default `spread_payload` so the trait's - * `getSpreadableSavingKey()` returns `cont`, matching the input - * name in Config/config.php and the entry in `$fillable`. Without - * this, SpreadHydrate would rewrite the input name to - * `spread_payload` and the form column would silently desync. + * @var list */ - protected static $spreadableSavingKey = 'cont'; - protected $fillable = [ - 'name', 'published', - 'cont', + 'site', + 'social', + 'contact', + 'seo', + 'smtp', + 'analytics', + 'locale', ]; + + /** + * @var array + */ + protected $casts = [ + 'site' => 'array', + 'social' => 'array', + 'contact' => 'array', + 'seo' => 'array', + 'smtp' => 'array', + 'analytics' => 'array', + 'locale' => 'array', + 'published' => 'boolean', + ]; + + /** + * @return list + */ + public static function settingsSections(): array + { + return ['site', 'social', 'contact', 'seo', 'smtp', 'analytics', 'locale']; + } } diff --git a/modules/SystemSetting/Providers/SystemSettingServiceProvider.php b/modules/SystemSetting/Providers/SystemSettingServiceProvider.php new file mode 100644 index 000000000..5e3d4a349 --- /dev/null +++ b/modules/SystemSetting/Providers/SystemSettingServiceProvider.php @@ -0,0 +1,76 @@ +app->singleton(SystemSettingsService::class); + $this->app->alias(SystemSettingsService::class, 'system.settings'); + + $this->app->singleton(AnalyticsScripts::class); + $this->app->singleton(ApplySmtpMailConfig::class); + $this->app->singleton(SystemSettingsInputMerger::class); + $this->app->singleton(MigrateRobotsTxtFromSiteSetting::class); + } + + public function boot(): void + { + $this->registerInputAliases(); + $this->mergeRouteInputs(); + + $this->app->make(ApplySmtpMailConfig::class)->apply(); + try { + } catch (\Throwable) { + // Mail override is best-effort when settings / DB are unavailable. + } + + if ($this->app->runningInConsole()) { + return; + } + + try { + $this->app->make(MigrateRobotsTxtFromSiteSetting::class)->migrateIfNeeded(); + } catch (\Throwable) { + // Migration is best-effort when CMS tables are absent. + } + } + + protected function mergeRouteInputs(): void + { + $module = Modularous::find('SystemSetting'); + + if ($module === null) { + return; + } + + $defaultInputs = (array) $module->getConfig('routes.general.inputs', []); + $merged = $this->app->make(SystemSettingsInputMerger::class)->merge($defaultInputs); + + $module->setConfig($merged, 'routes.general.inputs'); + } + + protected function registerInputAliases(): void + { + $aliases = (array) modularousConfig('system_settings.input_aliases', []); + + foreach ($aliases as $alias => $definition) { + if (! is_string($alias) || ! is_array($definition)) { + continue; + } + + config(["modularous.input_types.{$alias}" => $definition]); + } + } +} diff --git a/modules/SystemSetting/Repositories/GeneralRepository.php b/modules/SystemSetting/Repositories/GeneralRepository.php index a070964b9..aca9c5186 100644 --- a/modules/SystemSetting/Repositories/GeneralRepository.php +++ b/modules/SystemSetting/Repositories/GeneralRepository.php @@ -1,17 +1,97 @@ model = $model; } + + public function prepareFieldsBeforeCreate($fields) + { + $fields = $this->encryptSmtpPassword($fields); + + return parent::prepareFieldsBeforeCreate($fields); + } + + /** + * @param Model $object + * @param array $fields + * @return array + */ + public function prepareFieldsBeforeSave($object, $fields) + { + $fields = $this->encryptSmtpPassword($fields, $object); + + return parent::prepareFieldsBeforeSave($object, $fields); + } + + /** + * @param Model $object + * @param array $fields + */ + public function afterSave($object, $fields): void + { + parent::afterSave($object, $fields); + + if (app()->bound('system.settings')) { + app('system.settings')->forgetCache(); + } + + if (app()->bound('site.settings')) { + app('site.settings')->forgetCache(); + } + } + + /** + * @param array $fields + * @return array + */ + protected function encryptSmtpPassword(array $fields, mixed $object = null): array + { + $password = Arr::get($fields, 'smtp.password'); + + if ($password === null) { + return $fields; + } + + if ($password === '') { + if ($object !== null) { + $existing = is_array($object->smtp ?? null) ? $object->smtp : []; + Arr::set($fields, 'smtp.password', $existing['password'] ?? null); + } + + return $fields; + } + + if (is_string($password) && ! $this->alreadyEncrypted($password)) { + Arr::set($fields, 'smtp.password', encrypt($password, false)); + } + + return $fields; + } + + protected function alreadyEncrypted(string $value): bool + { + try { + decrypt($value, false); + + return true; + } catch (\Throwable) { + return false; + } + } } diff --git a/modules/SystemSetting/Services/SystemSettingsService.php b/modules/SystemSetting/Services/SystemSettingsService.php new file mode 100644 index 000000000..118bbde21 --- /dev/null +++ b/modules/SystemSetting/Services/SystemSettingsService.php @@ -0,0 +1,47 @@ +generalRepository; + } + + protected function settingsSections(): array + { + return General::settingsSections(); + } +} diff --git a/modules/SystemSetting/Support/AnalyticsScripts.php b/modules/SystemSetting/Support/AnalyticsScripts.php new file mode 100644 index 000000000..7a93c3cf3 --- /dev/null +++ b/modules/SystemSetting/Support/AnalyticsScripts.php @@ -0,0 +1,132 @@ +settings->get('analytics.enabled'); + + if ($db === null || $db === '') { + return (bool) modularousConfig( + 'system_settings.analytics_enabled_default', + env('MODULAROUS_ANALYTICS_ENABLED', false) + ); + } + + return filter_var($db, FILTER_VALIDATE_BOOLEAN); + } + + public function gtmId(): ?string + { + return $this->normalizeId( + $this->settings->get('analytics.gtm_id'), + '/^GTM-[A-Z0-9]+$/i' + ); + } + + public function gaMeasurementId(): ?string + { + return $this->normalizeId( + $this->settings->get('analytics.ga_measurement_id'), + '/^G-[A-Z0-9]+$/i' + ); + } + + /** + * GTM bootstrap + optional gtag.js for GA4 (only when measurement ID is set). + */ + public function headHtml(): string + { + if (! $this->isEnabled()) { + return ''; + } + + $parts = []; + + $gtmId = $this->gtmId(); + if ($gtmId !== null) { + $escaped = e($gtmId); + $parts[] = << + + +HTML; + } + + $gaId = $this->gaMeasurementId(); + if ($gaId !== null) { + $escaped = e($gaId); + $parts[] = << + + +HTML; + } + + return implode("\n", $parts); + } + + /** + * GTM noscript iframe for immediately after the opening body tag. + */ + public function bodyOpenHtml(): string + { + if (! $this->isEnabled()) { + return ''; + } + + $gtmId = $this->gtmId(); + if ($gtmId === null) { + return ''; + } + + $escaped = e($gtmId); + + return << + + +HTML; + } + + protected function normalizeId(mixed $value, string $pattern): ?string + { + if (! is_string($value) && ! is_numeric($value)) { + return null; + } + + $id = trim((string) $value); + if ($id === '' || ! preg_match($pattern, $id)) { + return null; + } + + return strtoupper($id); + } +} diff --git a/modules/SystemSetting/Support/ApplySmtpMailConfig.php b/modules/SystemSetting/Support/ApplySmtpMailConfig.php new file mode 100644 index 000000000..78b3b92d6 --- /dev/null +++ b/modules/SystemSetting/Support/ApplySmtpMailConfig.php @@ -0,0 +1,80 @@ + 'smtp', + 'host' => $host, + 'port' => is_numeric($port) ? (int) $port : null, + 'username' => is_string($username) && $username !== '' ? $username : null, + 'password' => is_string($password) && $password !== '' ? $password : null, + // 'scheme' => $this->mapEncryptionToScheme($encryption), + ], static fn ($value) => $value !== null); + + config([ + 'mail.default' => 'smtp', + 'mail.mailers.smtp' => array_merge( + (array) config('mail.mailers.smtp', []), + $mailerConfig, + ), + ]); + + if (is_string($fromAddress) && $fromAddress !== '') { + config(['mail.from.address' => $fromAddress]); + } + + if (is_string($fromName) && $fromName !== '') { + config(['mail.from.name' => $fromName]); + } + } + + /** + * Map admin encryption values to Laravel SMTP mailer schemes. + * + * Laravel only accepts "smtp" and "smtps" as schemes. + */ + private function mapEncryptionToScheme(mixed $encryption): ?string + { + if (! is_string($encryption) || $encryption === '') { + return null; + } + + return match (strtolower($encryption)) { + 'ssl', 'smtps' => 'smtps', + 'tls', 'smtp' => 'smtp', + default => 'smtp', + }; + } +} diff --git a/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php b/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php new file mode 100644 index 000000000..4d6dec839 --- /dev/null +++ b/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php @@ -0,0 +1,79 @@ +legacyRobotsKeys(); + + $row = DB::table($legacyTable) + ->where('group_key', $group) + ->where('key', $key) + ->where('locale', $locale) + ->whereNull('deleted_at') + ->first(); + + if ($row === null || trim((string) ($row->value ?? '')) === '') { + return false; + } + + $general = General::single(); + $seo = is_array($general->seo) ? $general->seo : []; + $seo['robots_txt'] = rtrim((string) $row->value, "\r\n"); + + $this->generalRepository->update($general->id, [ + 'seo' => $seo, + ]); + + $this->systemSettings->forgetCache(); + + return true; + } + + /** + * @return array{0: string, 1: string, 2: string} + */ + protected function legacyRobotsKeys(): array + { + $cfg = (array) modularousConfig('cms_seo.robots.legacy_site_setting', []); + + return [ + (string) ($cfg['group_key'] ?? 'seo'), + (string) ($cfg['key'] ?? 'global_robots_txt'), + (string) ($cfg['locale'] ?? '*'), + ]; + } +} diff --git a/modules/SystemSetting/Support/SystemSettingsInputMerger.php b/modules/SystemSetting/Support/SystemSettingsInputMerger.php new file mode 100644 index 000000000..abe3c145f --- /dev/null +++ b/modules/SystemSetting/Support/SystemSettingsInputMerger.php @@ -0,0 +1,95 @@ +|string> $defaultInputs + * @return array> + */ + public function merge(array $defaultInputs): array + { + $config = (array) modularousConfig('system_settings', []); + $inputs = $this->expandAliasesInList($defaultInputs); + $inputs = $this->applyOverrides($inputs, (array) ($config['input_overrides'] ?? [])); + $inputs = array_merge($inputs, $this->expandAliasesInList((array) ($config['extra_inputs'] ?? []))); + + $hidden = array_values(array_filter((array) ($config['hidden_inputs'] ?? []))); + + if ($hidden === []) { + return $inputs; + } + + return array_values(array_filter( + $inputs, + fn (array $input) => ! in_array((string) ($input['name'] ?? ''), $hidden, true) + )); + } + + /** + * @param array> $inputs + * @param array> $overrides + * @return array> + */ + protected function applyOverrides(array $inputs, array $overrides): array + { + if ($overrides === []) { + return $inputs; + } + + return array_map(function (array $input) use ($overrides): array { + $name = (string) ($input['name'] ?? ''); + + if ($name === '' || ! isset($overrides[$name])) { + return $input; + } + + return array_replace_recursive($input, $overrides[$name]); + }, $inputs); + } + + /** + * @param array|string> $inputs + * @return array> + */ + protected function expandAliasesInList(array $inputs): array + { + $resolved = []; + + foreach ($inputs as $input) { + if (is_string($input)) { + $definition = modularousConfig('input_types.' . $input); + if (is_array($definition)) { + $resolved[] = $definition; + + continue; + } + + $hydrated = function_exists('hydrate_input_type') + ? hydrate_input_type(['type' => $input]) + : null; + if (is_array($hydrated)) { + $resolved[] = $hydrated; + } + + continue; + } + + if (is_array($input)) { + if (isset($input['type']) && is_string($input['type']) && str_starts_with($input['type'], '@')) { + $input = function_exists('hydrate_input_type') ? hydrate_input_type($input) : $input; + } + + $resolved[] = $input; + } + } + + return $resolved; + } +} diff --git a/modules/SystemSetting/Transformers/GeneralResource.php b/modules/SystemSetting/Transformers/GeneralResource.php index e451b44b6..9569b6950 100644 --- a/modules/SystemSetting/Transformers/GeneralResource.php +++ b/modules/SystemSetting/Transformers/GeneralResource.php @@ -1,16 +1,33 @@ */ public function toArray($request): array { - return parent::toArray($request); + $data = parent::toArray($request); + + if (isset($data['smtp']) && is_array($data['smtp']) && array_key_exists('password', $data['smtp'])) { + $password = $data['smtp']['password']; + $data['smtp']['password'] = ($password !== null && $password !== '') ? '********' : null; + } + + foreach ((array) modularousConfig('system_settings.sensitive_keys', ['smtp.password']) as $dotKey) { + if (Arr::has($data, $dotKey)) { + $value = Arr::get($data, $dotKey); + Arr::set($data, $dotKey, ($value !== null && $value !== '') ? '********' : null); + } + } + + return $data; } } diff --git a/modules/SystemSetting/module.json b/modules/SystemSetting/module.json index 2a14a347b..9fa86d6fa 100644 --- a/modules/SystemSetting/module.json +++ b/modules/SystemSetting/module.json @@ -5,7 +5,7 @@ "keywords": [], "priority": 0, "providers": [ - + "Modules\\SystemSetting\\Providers\\SystemSettingServiceProvider" ], "files": [] } From 42a09b78c6a436ccb3169442cd42e1175ee3b027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:36:06 +0300 Subject: [PATCH 15/26] feat(CmsSettings): implement new site settings management with structured sections for site, social, contact, and SEO; deprecate legacy SEO settings handling --- modules/Cms/Config/config.php | 60 +++-- modules/Cms/Entities/SiteSetting.php | 42 +++- .../Controllers/SiteSeoSettingsController.php | 21 +- .../Controllers/SiteSeoToolController.php | 29 ++- .../Cms/Http/Requests/SiteSettingRequest.php | 8 +- modules/Cms/Providers/CmsServiceProvider.php | 14 ++ .../Repositories/SiteSettingRepository.php | 49 ++-- .../views/layout_builder/shell.blade.php | 37 ++- modules/Cms/Routes/web.php | 3 +- modules/Cms/Services/CmsSettingsService.php | 47 ++++ .../Services/CmsSiteSeoSettingsService.php | 45 ++-- modules/Cms/Services/SiteSettingsService.php | 219 ++++++++++++++++++ .../DuplicateSystemSettingsToCmsSettings.php | 72 ++++++ .../Cms/Transformers/SiteSettingResource.php | 33 +++ 14 files changed, 548 insertions(+), 131 deletions(-) create mode 100644 modules/Cms/Services/CmsSettingsService.php create mode 100644 modules/Cms/Services/SiteSettingsService.php create mode 100644 modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php create mode 100644 modules/Cms/Transformers/SiteSettingResource.php diff --git a/modules/Cms/Config/config.php b/modules/Cms/Config/config.php index 7473e9f05..2f18c29e0 100644 --- a/modules/Cms/Config/config.php +++ b/modules/Cms/Config/config.php @@ -442,22 +442,52 @@ 'url' => 'site-settings', 'route_name' => 'site_setting', 'icon' => 'mdi-cog-sync-outline', - 'title_column_key' => 'key', - 'table_options' => [ - 'createOnModal' => true, - 'editOnModal' => true, - ], - 'headers' => [ - ['title' => 'Group', 'key' => 'group_key', 'searchable' => true], - ['title' => 'Key', 'key' => 'key', 'searchable' => true], - ['title' => 'Locale', 'key' => 'locale', 'searchable' => true], - ['title' => 'Actions', 'key' => 'actions', 'sortable' => false], - ], + 'title_column_key' => 'site.name', 'inputs' => [ - ['name' => 'group_key', 'label' => 'Group', 'type' => 'text', 'rules' => 'required'], - ['name' => 'key', 'label' => 'Key', 'type' => 'text', 'rules' => 'required'], - ['name' => 'locale', 'label' => 'Locale', 'type' => 'locale', 'rules' => 'required'], - ['name' => 'value', 'label' => 'Value', 'type' => 'textarea'], + [ + 'type' => '@collapsible_group', + 'name' => 'site', + 'typeIntTitle' => 'Site', + 'schema' => [ + ['name' => 'name', 'type' => 'text', 'label' => 'Site name', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'tagline', 'type' => 'text', 'label' => 'Tagline', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'logo', 'type' => 'image', 'label' => 'Logo', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'favicon', 'type' => 'image', 'label' => 'Favicon', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'email', 'type' => 'text', 'label' => 'Email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'phone', 'type' => 'input-phone', 'label' => 'Phone', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'translated' => true, 'rules' => 'nullable|string'], + ], + ], + [ + 'type' => '@collapsible_wrap', + 'typeIntTitle' => 'Social Links', + 'schema' => [ + ['type' => '@system_social_links'], + ], + ], + [ + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Contact', + 'name' => 'contact', + 'schema' => [ + ['name' => 'support_email', 'type' => 'text', 'label' => 'Support email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'sales_email', 'type' => 'text', 'label' => 'Sales email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'cc', 'type' => 'text', 'label' => 'Contact CC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'bcc', 'type' => 'text', 'label' => 'Contact BCC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ], + ], + [ + 'type' => '@collapsible_group', + 'name' => 'seo', + 'typeIntTitle' => 'SEO', + 'schema' => [ + ['name' => 'robots_txt', 'type' => 'textarea', 'label' => 'Global robots.txt', 'rules' => 'nullable|string'], + ['name' => 'default_meta_title', 'type' => 'text', 'label' => 'Default meta title', 'translated' => true, 'rules' => 'nullable|string|max:255'], + ['name' => 'default_meta_description', 'type' => 'textarea', 'label' => 'Default meta description', 'translated' => true, 'rules' => 'nullable|string'], + ['name' => 'og_image', 'type' => 'image', 'label' => 'Default OG image', 'rules' => 'nullable'], + ['name' => 'json_schema', 'type' => 'textarea', 'label' => 'Global JSON-LD (Schema.org)', 'rules' => 'nullable|string', 'hint' => 'Raw JSON-LD object/array for the site homepage defaults'], + ], + ], ], ], 'redirect' => [ diff --git a/modules/Cms/Entities/SiteSetting.php b/modules/Cms/Entities/SiteSetting.php index 029dbcafa..34d6646cc 100644 --- a/modules/Cms/Entities/SiteSetting.php +++ b/modules/Cms/Entities/SiteSetting.php @@ -1,25 +1,51 @@ + */ protected $fillable = [ - 'group_key', - 'key', - 'locale', - 'value', - 'is_active', + 'published', + 'site', + 'social', + 'contact', + 'seo', ]; + /** + * @var array + */ protected $casts = [ - 'is_active' => 'boolean', + 'site' => 'array', + 'social' => 'array', + 'contact' => 'array', + 'seo' => 'array', + 'published' => 'boolean', ]; - public function getTable(): string + /** + * @return list + */ + public static function settingsSections(): array { - return modularousConfig('tables.cms_site_settings', 'um_cms_site_settings'); + return ['site', 'social', 'contact', 'seo']; } } diff --git a/modules/Cms/Http/Controllers/SiteSeoSettingsController.php b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php index 706e8812d..d67fd3388 100644 --- a/modules/Cms/Http/Controllers/SiteSeoSettingsController.php +++ b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php @@ -5,28 +5,17 @@ use Illuminate\Http\JsonResponse; use Illuminate\Routing\Controller; use Modules\Cms\Http\Requests\SiteSeoSettingsRequest; -use Modules\Cms\Services\CmsSiteSeoSettingsService; /** - * Persists site-wide SEO fields (session web route for the panel). + * @deprecated Global robots.txt is edited under System Settings → General. */ class SiteSeoSettingsController extends Controller { - public function update(SiteSeoSettingsRequest $request, CmsSiteSeoSettingsService $service): JsonResponse + public function update(SiteSeoSettingsRequest $request): JsonResponse { - if (! modularousConfig('cms_seo.robots.use_site_settings', true)) { - return response()->json([ - 'ok' => false, - 'message' => __('Database-backed site SEO is disabled in configuration.'), - ], 422); - } - - $data = $request->validated(); - $service->saveGlobalRobotsTxt($data['global_robots_txt'] ?? null); - return response()->json([ - 'ok' => true, - 'message' => __('Site SEO settings saved.'), - ]); + 'ok' => false, + 'message' => __('Global robots.txt is managed under System Settings → General.'), + ], 410); } } diff --git a/modules/Cms/Http/Controllers/SiteSeoToolController.php b/modules/Cms/Http/Controllers/SiteSeoToolController.php index fd3bb3f79..639a26997 100644 --- a/modules/Cms/Http/Controllers/SiteSeoToolController.php +++ b/modules/Cms/Http/Controllers/SiteSeoToolController.php @@ -7,12 +7,11 @@ use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Inertia\Response; -use Modules\Cms\Services\CmsSiteSeoSettingsService; use Unusualify\Modularous\Facades\Modularous; use Unusualify\Modularous\Http\Controllers\BaseController; /** - * Inertia shell for CMS site-wide SEO (global robots.txt body stored in site_settings). + * Inertia shell for CMS site-wide SEO tools (page-level SEO; robots.txt lives in System Settings). */ class SiteSeoToolController extends BaseController { @@ -25,7 +24,7 @@ public function __construct(Application $app, Request $request) parent::__construct($app, $request); } - public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response + public function __invoke(): Response { $pageTitle = __('Site SEO') . ' - ' . Modularous::pageTitle(); $headerTitle = __('Site SEO'); @@ -40,20 +39,32 @@ public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response $this->shareInertiaStoreVariables(); - $prefix = $this->module->panelRouteNamePrefix() . '.'; + $systemSettingsUrl = $this->resolveSystemSettingsUrl(); return Inertia::render('SiteSeo', [ - 'siteSeoEndpoints' => [ - 'save' => route($prefix . 'siteSeo.save'), - ], - 'globalRobotsTxt' => $siteSeo->globalRobotsTxtForEditor(), - 'useSiteSettings' => (bool) modularousConfig('cms_seo.robots.use_site_settings', true), + 'systemSettingsUrl' => $systemSettingsUrl, 'endpoints' => new \stdClass, 'mainConfiguration' => $this->getInertiaMainConfiguration($data), 'headLayoutData' => $this->getHeadLayoutData($data), ]); } + protected function resolveSystemSettingsUrl(): ?string + { + $candidates = [ + systemRouteNamePrefix() . '.systemsetting.general.index', + modularousConfig('admin_route_name_prefix', 'admin') . '.system.systemsetting.general.index', + ]; + + foreach ($candidates as $routeName) { + if (Route::has($routeName)) { + return route($routeName); + } + } + + return null; + } + /** * @return array */ diff --git a/modules/Cms/Http/Requests/SiteSettingRequest.php b/modules/Cms/Http/Requests/SiteSettingRequest.php index 1500809da..4d63afd7e 100644 --- a/modules/Cms/Http/Requests/SiteSettingRequest.php +++ b/modules/Cms/Http/Requests/SiteSettingRequest.php @@ -8,13 +8,7 @@ class SiteSettingRequest extends Request { public function rulesForAll() { - return [ - 'group_key' => 'required|string|max:100', - 'key' => 'required|string|max:100', - 'locale' => 'required|string|max:12', - 'value' => 'nullable|string', - 'is_active' => 'nullable|boolean', - ]; + return []; } public function rulesForCreate() diff --git a/modules/Cms/Providers/CmsServiceProvider.php b/modules/Cms/Providers/CmsServiceProvider.php index 62405903c..6da2fbe53 100644 --- a/modules/Cms/Providers/CmsServiceProvider.php +++ b/modules/Cms/Providers/CmsServiceProvider.php @@ -46,6 +46,7 @@ use Modules\Cms\Services\CmsParentSegmentResolver; use Modules\Cms\Services\CmsPromotionService; use Modules\Cms\Services\CmsPublicModelResolver; +use Modules\Cms\Services\CmsSettingsService; use Modules\Cms\Services\CmsSignedPreviewTargetResolver; use Modules\Cms\Services\CmsSignedPreviewUrlGenerator; use Modules\Cms\Services\CmsSitemapBuildService; @@ -58,6 +59,7 @@ use Modules\Cms\Services\DefaultCmsPromotionScopeApplier; use Modules\Cms\Services\NullLeadDelivery; use Modules\Cms\Services\RedirectValidationService; +use Modules\Cms\Services\SiteSettingsService; use Modules\Cms\Services\Stylesheet\FrameworkArtifactResolver; use Modules\Cms\Services\Stylesheet\FrameworkScriptResolver; use Modules\Cms\Services\Stylesheet\RootVariablesEmitter; @@ -66,6 +68,7 @@ use Modules\Cms\Services\Stylesheet\UtilityCssGenerator; use Modules\Cms\Support\CmsPublicUrlRegistryAboutReporter; use Modules\Cms\Support\CmsPublicUrlRegistryCacheManager; +use Modules\Cms\Support\DuplicateSystemSettingsToCmsSettings; use Unusualify\Modularous\Facades\ModularousCache; use Unusualify\Modularous\Services\Security\SecurityService; use Unusualify\Modularous\Services\SlugInputValidationService; @@ -110,6 +113,11 @@ public function register(): void $this->app->singleton(CmsSitemapCacheService::class); $this->app->singleton(CmsAdminWarnings::class); $this->app->singleton(CmsSiteSeoSettingsService::class); + $this->app->singleton(CmsSettingsService::class); + $this->app->alias(CmsSettingsService::class, 'cms.settings'); + $this->app->singleton(SiteSettingsService::class); + $this->app->alias(SiteSettingsService::class, 'site.settings'); + $this->app->singleton(DuplicateSystemSettingsToCmsSettings::class); $this->app->singleton(SlugInputValidationService::class, CmsSlugInputValidationService::class); $this->app->singleton(CmsSignedPreviewUrlGenerator::class); $this->app->singleton(CmsSignedPreviewTargetResolver::class); @@ -188,6 +196,12 @@ public function boot(): void $this->registerCmsSignedPreviewRoutes(); $this->registerCmsPublicStylesheetRoutes(); $this->registerCmsPublishSchedule(); + + try { + $this->app->make(DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded(); + } catch (\Throwable) { + // Seed is best-effort when singletons / DB are unavailable. + } } /** diff --git a/modules/Cms/Repositories/SiteSettingRepository.php b/modules/Cms/Repositories/SiteSettingRepository.php index 4c8ee11e2..e56faa0d6 100644 --- a/modules/Cms/Repositories/SiteSettingRepository.php +++ b/modules/Cms/Repositories/SiteSettingRepository.php @@ -1,58 +1,37 @@ model = $model; } /** - * Single site-setting row for the given composite key (includes soft-deleted for restore semantics). + * @param \Illuminate\Database\Eloquent\Model $object + * @param array $fields */ - public function findScoped(string $groupKey, string $key, string $locale): ?SiteSetting + public function afterSave($object, $fields): void { - return $this->model->newQuery() - ->withTrashed() - ->where('group_key', $groupKey) - ->where('key', $key) - ->where('locale', $locale) - ->first(); - } + parent::afterSave($object, $fields); - /** - * Persist a value or remove the row when {@code $value} is null or whitespace-only (revert to env/config fallback). - */ - public function putScoped(string $groupKey, string $key, string $locale, ?string $value): void - { - if ($value === null || trim($value) === '') { - $existing = $this->findScoped($groupKey, $key, $locale); - if ($existing !== null) { - $existing->forceDelete(); - } - - return; + if (app()->bound('cms.settings')) { + app('cms.settings')->forgetCache(); } - $model = $this->findScoped($groupKey, $key, $locale) ?? $this->model->newInstance([ - 'group_key' => $groupKey, - 'key' => $key, - 'locale' => $locale, - ]); - - if ($model->trashed()) { - $model->restore(); + if (app()->bound('site.settings')) { + app('site.settings')->forgetCache(); } - - $model->fill([ - 'value' => $value, - 'is_active' => true, - ]); - $model->save(); } } diff --git a/modules/Cms/Resources/views/layout_builder/shell.blade.php b/modules/Cms/Resources/views/layout_builder/shell.blade.php index 381e29e41..089f63cb0 100644 --- a/modules/Cms/Resources/views/layout_builder/shell.blade.php +++ b/modules/Cms/Resources/views/layout_builder/shell.blade.php @@ -5,25 +5,44 @@ - @if(! empty($seoDescription)) - - @endif - @if(! empty($canonicalUrl)) - - @endif - @if(! empty($robotsMeta)) - - @endif + + @php + $resolvedSiteName = SiteSettings::get('site.name', config('app.name')); + $resolvedMetaTitle = $seoTitle ?? $siteTitle ?? SiteSettings::get('seo.default_meta_title', $resolvedSiteName); + $resolvedMetaDescription = $seoDescription ?? SiteSettings::get('seo.default_meta_description', ''); + $resolvedFavicon = SiteSettings::value('site.favicon.original') ?: asset('favicon.ico'); + @endphp + + {{ $resolvedMetaTitle }} + + + + + + + + + + + + {{-- Optional CMP / consent (e.g. CookieFirst) must precede GTM --}} + {!! $consentHeadHtml ?? '' !!} + {!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->headHtml() !!} + + + @foreach ($stylesheetHrefs ?? [] as $href) @endforeach + {!! $headHtml ?? '' !!} @if (! empty($cmsLayoutUseInjectionMarkers)) {!! $cmsLayoutMarkerHeadAppend ?? '' !!} @endif + {!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->bodyOpenHtml() !!} {!! $bodyHtml ?? '' !!} @if (! empty($cmsLayoutUseInjectionMarkers)) {!! $cmsLayoutMarkerBeforeFooter ?? '' !!} diff --git a/modules/Cms/Routes/web.php b/modules/Cms/Routes/web.php index e03099076..da2b5063b 100644 --- a/modules/Cms/Routes/web.php +++ b/modules/Cms/Routes/web.php @@ -2,10 +2,11 @@ use Illuminate\Support\Facades\Route; use Modules\Cms\Http\Controllers\API\PromotionController; +use Modules\Cms\Http\Controllers\LayoutBuilderHtmlPreviewController; +use Modules\Cms\Http\Controllers\LayoutBuilderShellDraftPreviewController; use Modules\Cms\Http\Controllers\PromotionToolController; use Modules\Cms\Http\Controllers\SignedPublicPreviewMintController; use Modules\Cms\Http\Controllers\SitemapController; -use Modules\Cms\Http\Controllers\SiteSeoSettingsController; use Modules\Cms\Http\Controllers\SiteSeoToolController; use Unusualify\Modularous\Facades\ModularousRoutes; diff --git a/modules/Cms/Services/CmsSettingsService.php b/modules/Cms/Services/CmsSettingsService.php new file mode 100644 index 000000000..628b0d3b4 --- /dev/null +++ b/modules/Cms/Services/CmsSettingsService.php @@ -0,0 +1,47 @@ +siteSettingRepository; + } + + protected function settingsSections(): array + { + return SiteSetting::settingsSections(); + } +} diff --git a/modules/Cms/Services/CmsSiteSeoSettingsService.php b/modules/Cms/Services/CmsSiteSeoSettingsService.php index 7d1475ab7..d53226230 100644 --- a/modules/Cms/Services/CmsSiteSeoSettingsService.php +++ b/modules/Cms/Services/CmsSiteSeoSettingsService.php @@ -2,23 +2,15 @@ namespace Modules\Cms\Services; -use Modules\Cms\Entities\SiteSetting; -use Modules\Cms\Http\Controllers\Front\RobotsTxtController; -use Modules\Cms\Repositories\SiteSettingRepository; use Modules\Cms\Support\CmsPublicSeo; +use Unusualify\Modularous\Facades\SiteSettings; /** - * Persists site-wide SEO options in {@see SiteSetting} (key-value rows). - * - * Global robots.txt body is stored under the configured group/key/locale and read by - * {@see RobotsTxtController} when `cms_seo.robots.use_site_settings` is true. + * Site-wide SEO helpers. Global robots.txt is resolved via {@see SiteSettings} + * (CmsSettings on frontend with SystemSettings fallback). */ class CmsSiteSeoSettingsService { - public function __construct( - protected SiteSettingRepository $siteSettings, - ) {} - /** * Body served at GET /robots.txt (normalized trailing newline). */ @@ -31,7 +23,7 @@ public function resolvedRobotsTxtBody(): string $default = "User-agent: *\nAllow: /"; $raw = null; - if (modularousConfig('cms_seo.robots.use_site_settings', true)) { + if (modularousConfig('cms_seo.robots.use_system_settings', true)) { $persisted = $this->persistedGlobalRobotsTxt(); if ($persisted !== null) { $raw = trim($persisted); @@ -53,14 +45,17 @@ public function resolvedRobotsTxtBody(): string } /** - * Raw value from DB, or null when unset (use env/config in UI and public fallback). + * Raw value from SiteSettings, or null when unset (use env/config in UI and public fallback). */ public function persistedGlobalRobotsTxt(): ?string { - [$g, $k, $locale] = $this->robotsSettingKeys(); - $row = $this->siteSettings->findScoped($g, $k, $locale); + if (! SiteSettings::forFrontend()->has('seo.robots_txt')) { + return null; + } - return $row !== null ? (string) $row->value : null; + $value = SiteSettings::forFrontend()->get('seo.robots_txt'); + + return $value !== null ? (string) $value : null; } /** @@ -88,21 +83,9 @@ public function globalRobotsTxtForEditor(): string public function saveGlobalRobotsTxt(?string $value): void { - [$g, $k, $locale] = $this->robotsSettingKeys(); - $this->siteSettings->putScoped($g, $k, $locale, $value); - } - - /** - * @return array{0: string, 1: string, 2: string} - */ - protected function robotsSettingKeys(): array - { - $cfg = (array) modularousConfig('cms_seo.robots.site_setting', []); + $normalized = $value === null || trim($value) === '' ? null : rtrim($value, "\r\n"); - return [ - (string) ($cfg['group_key'] ?? 'seo'), - (string) ($cfg['key'] ?? 'global_robots_txt'), - (string) ($cfg['locale'] ?? '*'), - ]; + // Panel SEO tool writes the system-wide default; CMS Site Settings can override per frontend. + SiteSettings::forBackend()->set('seo.robots_txt', $normalized); } } diff --git a/modules/Cms/Services/SiteSettingsService.php b/modules/Cms/Services/SiteSettingsService.php new file mode 100644 index 000000000..7340c83e5 --- /dev/null +++ b/modules/Cms/Services/SiteSettingsService.php @@ -0,0 +1,219 @@ +forcedContext = 'frontend'; + + return $clone; + } + + /** + * Force SystemSettings only (backend semantics). + */ + public function forBackend(): static + { + $clone = clone $this; + $clone->forcedContext = 'backend'; + + return $clone; + } + + /** + * Alias of {@see forFrontend()}. + */ + public function forCms(): static + { + return $this->forFrontend(); + } + + /** + * Alias of {@see forBackend()}. + */ + public function forSystem(): static + { + return $this->forBackend(); + } + + public function usesCmsLayer(): bool + { + if ($this->forcedContext === 'frontend') { + return true; + } + + if ($this->forcedContext === 'backend') { + return false; + } + + if (app()->runningInConsole()) { + return false; + } + + try { + return ! Modularous::isPanelUrl(); + } catch (\Throwable) { + return true; + } + } + + public function get(string $key, mixed $default = null, ?string $locale = null): mixed + { + if ($this->usesCmsLayer()) { + if ($this->cmsSettings->filled($key, $locale)) { + return $this->cmsSettings->get($key, null, $locale); + } + + return $this->systemSettings->get($key, $default, $locale); + } + + return $this->systemSettings->get($key, $default, $locale); + } + + public function value(string $key, mixed $default = null, ?string $locale = null): mixed + { + return $this->get($key, $default, $locale); + } + + public function first(string $key, mixed $default = null, ?string $locale = null): mixed + { + if ($this->usesCmsLayer()) { + if ($this->cmsSettings->filled($key, $locale)) { + return $this->cmsSettings->first($key, null, $locale); + } + + return $this->systemSettings->first($key, $default, $locale); + } + + return $this->systemSettings->first($key, $default, $locale); + } + + /** + * @return array + */ + public function all(?string $locale = null): array + { + if (! $this->usesCmsLayer()) { + return $this->systemSettings->all($locale); + } + + return array_replace_recursive( + $this->systemSettings->all($locale), + $this->filterEmptyBranches($this->cmsSettings->all($locale)), + ); + } + + public function has(string $key): bool + { + if ($this->usesCmsLayer()) { + return $this->cmsSettings->has($key) || $this->systemSettings->has($key); + } + + return $this->systemSettings->has($key); + } + + public function filled(string $key, ?string $locale = null): bool + { + if ($this->usesCmsLayer()) { + return $this->cmsSettings->filled($key, $locale) || $this->systemSettings->filled($key, $locale); + } + + return $this->systemSettings->filled($key, $locale); + } + + public function set(string $key, mixed $value): void + { + if ($this->usesCmsLayer()) { + $this->cmsSettings->set($key, $value); + + return; + } + + $this->systemSettings->set($key, $value); + } + + public function forgetCache(): void + { + $this->cmsSettings->forgetCache(); + $this->systemSettings->forgetCache(); + } + + /** + * @return array + */ + public function snapshot(): array + { + if ($this->usesCmsLayer()) { + return array_replace_recursive( + $this->systemSettings->snapshot(), + $this->filterEmptyBranches($this->cmsSettings->snapshot()), + ); + } + + return $this->systemSettings->snapshot(); + } + + public function cms(): CmsSettingsService + { + return $this->cmsSettings; + } + + public function system(): SystemSettingsService + { + return $this->systemSettings; + } + + /** + * Drop empty leaves so SystemSettings values remain visible under array_replace_recursive. + * + * @param array $tree + * @return array + */ + protected function filterEmptyBranches(array $tree): array + { + $filtered = []; + + foreach ($tree as $key => $value) { + if (is_array($value)) { + $nested = $this->filterEmptyBranches($value); + if ($nested !== []) { + $filtered[$key] = $nested; + } + + continue; + } + + if ($value !== null && $value !== '') { + $filtered[$key] = $value; + } + } + + return $filtered; + } +} diff --git a/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php b/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php new file mode 100644 index 000000000..499c4931d --- /dev/null +++ b/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php @@ -0,0 +1,72 @@ +hasMeaningfulContent($siteSetting)) { + return false; + } + + if (! $this->hasMeaningfulContent($general)) { + return false; + } + + $fields = ['published' => (bool) ($general->published ?? true)]; + + foreach (SiteSetting::settingsSections() as $section) { + $value = $general->getAttribute($section); + $fields[$section] = is_array($value) ? $value : []; + } + + $this->siteSettingRepository->update($siteSetting->id, $fields); + $this->cmsSettings->forgetCache(); + + return true; + } + + protected function hasMeaningfulContent(object $model): bool + { + foreach (SiteSetting::settingsSections() as $section) { + $value = $model->getAttribute($section); + + if (is_array($value) && $value !== []) { + return true; + } + + if ($value !== null && $value !== '' && $value !== []) { + return true; + } + } + + return false; + } +} diff --git a/modules/Cms/Transformers/SiteSettingResource.php b/modules/Cms/Transformers/SiteSettingResource.php new file mode 100644 index 000000000..8caad966f --- /dev/null +++ b/modules/Cms/Transformers/SiteSettingResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray($request): array + { + $data = parent::toArray($request); + + if (isset($data['smtp']) && is_array($data['smtp']) && array_key_exists('password', $data['smtp'])) { + $password = $data['smtp']['password']; + $data['smtp']['password'] = ($password !== null && $password !== '') ? '********' : null; + } + + foreach ((array) modularousConfig('cms_settings.sensitive_keys', modularousConfig('system_settings.sensitive_keys', ['smtp.password'])) as $dotKey) { + if (Arr::has($data, $dotKey)) { + $value = Arr::get($data, $dotKey); + Arr::set($data, $dotKey, ($value !== null && $value !== '') ? '********' : null); + } + } + + return $data; + } +} From 32d3dd53485f0dc9a3f8cf67790bd6e95eff1bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:36:32 +0300 Subject: [PATCH 16/26] feat(Merges): add new collapsible wrap and group input types for enhanced UI structure --- config/merges/input_types.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/config/merges/input_types.php b/config/merges/input_types.php index 14c80e655..23575bb6d 100644 --- a/config/merges/input_types.php +++ b/config/merges/input_types.php @@ -3,6 +3,24 @@ use Camroncade\Timezone\Timezone; return [ + '@collapsible_wrap' => [ + 'type' => 'wrap', + 'typeInt' => 'ue-collapsible', + 'typeIntModelValue' => false, + 'typeIntTitle' => 'Wrap', + 'noLabel' => true, + 'bordered' => true, + 'col' => ['cols' => 12,'sm' => 12,'md' => 12,'lg' => 12,'xl' => 12], + ], + '@collapsible_group' => [ + 'type' => 'group', + 'typeInt' => 'ue-collapsible', + 'typeIntModelValue' => false, + 'typeIntTitle' => 'Group', + 'noLabel' => true, + 'bordered' => true, + 'col' => ['cols' => 12,'sm' => 12,'md' => 12,'lg' => 12,'xl' => 12], + ], '_language' => [ 'type' => 'select', 'name' => 'language', From a6ab42c0efb369b5a17c18e782aae5ca79a11a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:36:49 +0300 Subject: [PATCH 17/26] feat(SystemSettings): add new configuration file for system settings with support for analytics, SMTP overrides, and social links management --- config/merges/system_settings.php | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 config/merges/system_settings.php diff --git a/config/merges/system_settings.php b/config/merges/system_settings.php new file mode 100644 index 000000000..7ad9b005c --- /dev/null +++ b/config/merges/system_settings.php @@ -0,0 +1,45 @@ + 'SystemSetting', + 'system_prefix' => true, + 'group' => 'system', + 'headline' => 'System Settings', + 'cache_ttl' => (int) env('MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL', 3600), + /** + * Fallback when SystemSettings `analytics.enabled` is unset in the DB. + * Staging should leave DB off (or unset) so this stays false. + * Prod: set analytics.enabled=true in System Settings (or MODULAROUS_ANALYTICS_ENABLED=true as seed default). + */ + 'analytics_enabled_default' => (bool) env('MODULAROUS_ANALYTICS_ENABLED', false), + /** + * When true, smtp.* from System Settings overrides config/mail.php. + * Prefer .env MAIL_* unless you explicitly need CMS-managed SMTP. + */ + 'mail_override_enabled' => (bool) env('MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE', false), + 'sensitive_keys' => [ + 'smtp.password', + ], + 'extra_inputs' => [], + 'input_overrides' => [], + 'hidden_inputs' => [], + 'input_aliases' => [ + '@system_social_links' => [ + 'type' => 'json-repeater', + 'name' => 'social', + 'label' => 'Social Links', + 'default' => [], + 'draggable' => true, + 'orderKey' => 'position', + 'noHeaders' => true, + 'collapsible' => true, + 'collapsibleTitleField' => 'platform', + 'collapsibleDefaultOpen' => false, + 'schema' => [ + ['name' => 'platform', 'type' => 'text', 'label' => 'Platform', 'rules' => 'required'], + ['name' => 'url', 'type' => 'text', 'label' => 'URL', 'rules' => 'required'], + ['name' => 'icon', 'type' => 'image', 'label' => 'Icon', 'rules' => 'nullable'], + ], + ] + ], +]; From 85ab33c7067398d69fce0a5cdd79b485e77da2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:00 +0300 Subject: [PATCH 18/26] feat(RobotsTxt): update robots.txt configuration to use system settings, deprecate legacy site settings, and introduce new cms_settings.php for cache and sensitive keys management --- config/merges/cms_seo.php | 13 +++++++++---- config/merges/cms_settings.php | 8 ++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 config/merges/cms_settings.php diff --git a/config/merges/cms_seo.php b/config/merges/cms_seo.php index 231f1968a..bf8d639d6 100644 --- a/config/merges/cms_seo.php +++ b/config/merges/cms_seo.php @@ -7,6 +7,7 @@ use Modules\Cms\Services\CmsAdminWarnings; use Modules\Cms\Services\CmsSiteSeoSettingsService; use Modules\Cms\Support\CmsPublicSeo; +use Modules\SystemSetting\Support\MigrateRobotsTxtFromSiteSetting; use Unusualify\Modularous\Entities\Traits\Core\HasScopes; return [ @@ -32,19 +33,23 @@ * Global robots.txt (served at GET /robots.txt when route enabled). * * @see RobotsTxtController + * @see CmsSiteSeoSettingsService */ 'robots' => [ 'route_enabled' => env('MODULAROUS_CMS_ROBOTS_TXT_ROUTE_ENABLED', true), 'global_robots_txt' => env('MODULAROUS_CMS_SEO_GLOBAL_ROBOTS_TXT', ''), /** - * When true, GET /robots.txt prefers {@see CmsSiteSeoSettingsService} (um_cms_site_settings). + * When true, GET /robots.txt prefers {@see CmsSiteSeoSettingsService} + * ({@see \Unusualify\Modularous\Facades\SiteSettings} → CmsSettings with SystemSettings fallback). * When false, only env/config {@code global_robots_txt} is used (legacy / headless deploys). */ - 'use_site_settings' => env('MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS', true), + 'use_system_settings' => env('MODULAROUS_CMS_SEO_ROBOTS_USE_SYSTEM_SETTINGS', env('MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS', true)), /** - * Composite key for the global robots.txt body row (must match unique index on site_settings). + * Legacy KV row keys used only by {@see MigrateRobotsTxtFromSiteSetting}. + * + * @deprecated Robots.txt is stored on SystemSetting General / Cms SiteSetting (IsSingular). */ - 'site_setting' => [ + 'legacy_site_setting' => [ 'group_key' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_GROUP', 'seo'), 'key' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_KEY', 'global_robots_txt'), 'locale' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_LOCALE', '*'), diff --git a/config/merges/cms_settings.php b/config/merges/cms_settings.php new file mode 100644 index 000000000..d019a3b96 --- /dev/null +++ b/config/merges/cms_settings.php @@ -0,0 +1,8 @@ + (int) env('MODULAROUS_CMS_SETTINGS_CACHE_TTL', env('MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL', 3600)), + 'sensitive_keys' => [ + 'smtp.password', + ], +]; From 32351d3541048b90167bb9aea5dacdca54e7daa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:15 +0300 Subject: [PATCH 19/26] feat(Settings): introduce new facade classes for CmsSettings, SiteSettings, and SystemSettings; enhance composer.json with new aliases for improved settings management --- composer.json | 5 +- src/Facades/CmsSettings.php | 29 ++ src/Facades/SiteSettings.php | 40 ++ src/Facades/SystemSettings.php | 29 ++ .../AbstractSingularSettingsService.php | 415 ++++++++++++++++++ 5 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 src/Facades/CmsSettings.php create mode 100644 src/Facades/SiteSettings.php create mode 100644 src/Facades/SystemSettings.php create mode 100644 src/Services/Settings/AbstractSingularSettingsService.php diff --git a/composer.json b/composer.json index ea7251820..c4d856d54 100755 --- a/composer.json +++ b/composer.json @@ -87,7 +87,10 @@ "Unusualify\\Modularous\\Providers\\ModularousProvider" ], "aliases": { - "ModularousVite": "Unusualify\\Modularous\\Facades\\ModularousVite" + "ModularousVite": "Unusualify\\Modularous\\Facades\\ModularousVite", + "SystemSettings": "Unusualify\\Modularous\\Facades\\SystemSettings", + "CmsSettings": "Unusualify\\Modularous\\Facades\\CmsSettings", + "SiteSettings": "Unusualify\\Modularous\\Facades\\SiteSettings" } }, "branch-alias": { diff --git a/src/Facades/CmsSettings.php b/src/Facades/CmsSettings.php new file mode 100644 index 000000000..0f8f59b80 --- /dev/null +++ b/src/Facades/CmsSettings.php @@ -0,0 +1,29 @@ +|null */ + private ?array $requestSnapshot = null; + + abstract protected function cacheKey(): string; + + abstract protected function cacheTtl(): int; + + /** + * @return class-string + */ + abstract protected function modelClass(): string; + + abstract protected function repository(): Repository; + + /** + * @return list + */ + abstract protected function settingsSections(): array; + + /** + * Get a nested setting by dot path, resolving locale maps along the way. + * + * When the direct path is empty and the key has at least two segments, expands through + * locale (+ optional first list index) using the last segment as an arbitrary leaf: + * `site.logo.frontend` → `site.logo.{locale}.frontend` + * → `site.logo.{locale}.0.frontend` + * → `site.logo.{locale}.frontend.0` + */ + public function get(string $key, mixed $default = null, ?string $locale = null): mixed + { + $resolvedLocale = $locale ?? app()->getLocale(); + $snapshot = $this->snapshot(); + $segments = $key === '' ? [] : explode('.', $key); + + $value = $this->walkPath($snapshot, $key, $resolvedLocale); + + if (! $this->isEmptySettingValue($value) && ! $this->isExpandableListResult($value, $segments)) { + return $value; + } + + if (count($segments) >= 2) { + $leaf = (string) $segments[array_key_last($segments)]; + $parentKey = implode('.', array_slice($segments, 0, -1)); + $expanded = $this->resolveLeafViaLocalePaths($snapshot, $parentKey, $leaf, $resolvedLocale); + + if (! $this->isEmptySettingValue($expanded)) { + return $expanded; + } + } + + if (! $this->isEmptySettingValue($value)) { + return $value; + } + + return $default; + } + + /** + * Alias of {@see get()}. + */ + public function value(string $key, mixed $default = null, ?string $locale = null): mixed + { + return $this->get($key, $default, $locale); + } + + /** + * Get a setting; when the resolved value is a list, return its first element. + */ + public function first(string $key, mixed $default = null, ?string $locale = null): mixed + { + $resolved = $this->get($key, null, $locale); + + if ($resolved === null) { + return $default; + } + + if (is_array($resolved) && Arr::isList($resolved)) { + return $resolved[0] ?? $default; + } + + return $resolved; + } + + /** + * @return array + */ + public function all(?string $locale = null): array + { + $snapshot = $this->snapshot(); + $resolvedLocale = $locale ?? app()->getLocale(); + + return $this->resolveTranslatedTree($snapshot, $resolvedLocale); + } + + public function has(string $key): bool + { + return Arr::has($this->snapshot(), $key); + } + + /** + * True when the resolved value for {@see $key} is non-empty (after locale/leaf expansion). + */ + public function filled(string $key, ?string $locale = null): bool + { + return ! $this->isEmptySettingValue($this->get($key, null, $locale)); + } + + public function set(string $key, mixed $value): void + { + $modelClass = $this->modelClass(); + $model = $modelClass::single(); + $fields = $this->snapshotFromModel($model); + Arr::set($fields, $key, $value); + + $this->repository()->update($model->id, $fields); + $this->forgetCache(); + } + + public function forgetCache(): void + { + $this->requestSnapshot = null; + Cache::forget($this->cacheKey()); + ModularousCache::forget($this->cacheKey()); + } + + /** + * @return array + */ + public function snapshot(): array + { + if ($this->requestSnapshot !== null) { + return $this->requestSnapshot; + } + + $ttl = $this->cacheTtl(); + + $this->requestSnapshot = Cache::remember($this->cacheKey(), $ttl, function (): array { + return $this->buildSnapshotFromDatabase(); + }); + + return $this->requestSnapshot; + } + + /** + * @return array + */ + protected function buildSnapshotFromDatabase(): array + { + $modelClass = $this->modelClass(); + + if (! class_exists($modelClass)) { + return $this->defaultSnapshot(); + } + + try { + $model = $modelClass::single(); + } catch (\Throwable) { + return $this->defaultSnapshot(); + } + + return $this->snapshotFromModel($model); + } + + /** + * @param TModel $model + * @return array + */ + protected function snapshotFromModel(object $model): array + { + $snapshot = $this->defaultSnapshot(); + + foreach ($this->settingsSections() as $section) { + $value = $model->getAttribute($section); + if ($value !== null) { + $snapshot[$section] = is_array($value) ? $value : []; + } + } + + if (isset($snapshot['smtp']['password']) && is_string($snapshot['smtp']['password']) && $snapshot['smtp']['password'] !== '') { + $snapshot['smtp']['password'] = $this->decryptSecret($snapshot['smtp']['password']); + } + + return $snapshot; + } + + /** + * @return array + */ + protected function defaultSnapshot(): array + { + $snapshot = []; + + foreach ($this->settingsSections() as $section) { + $snapshot[$section] = []; + } + + return $snapshot; + } + + protected function decryptSecret(string $value): string + { + try { + return decrypt($value, false); + } catch (DecryptException) { + return $value; + } + } + + /** + * @param array $data + */ + protected function walkPath(array $data, string $key, string $locale): mixed + { + if ($key === '') { + return $this->resolveTranslatedValue($data, $locale); + } + + $cursor = $data; + + foreach (explode('.', $key) as $segment) { + if (is_array($cursor) && $this->looksLikeLocaleMap($cursor)) { + $cursor = $this->resolveTranslatedValue($cursor, $locale); + } + + if (! is_array($cursor) || ! array_key_exists($segment, $cursor)) { + return null; + } + + $cursor = $cursor[$segment]; + } + + return $this->resolveTranslatedValue($cursor, $locale); + } + + /** + * @param array $data + */ + protected function resolveLeafViaLocalePaths(array $data, string $parentKey, string $leaf, string $locale): mixed + { + $parent = $parentKey === '' + ? $data + : $this->walkPathRaw($data, $parentKey); + + if (! is_array($parent)) { + return null; + } + + $fallback = (string) modularousConfig('fallback_locale', config('app.fallback_locale', 'en')); + $locales = []; + + foreach ([$locale, $fallback, '*'] as $candidate) { + if ($candidate === '' || in_array($candidate, $locales, true)) { + continue; + } + + $locales[] = $candidate; + } + + foreach ($locales as $tryLocale) { + if (! array_key_exists($tryLocale, $parent)) { + continue; + } + + $listDeferred = null; + + foreach ([ + "{$tryLocale}.{$leaf}", + "{$tryLocale}.0.{$leaf}", + "{$tryLocale}.{$leaf}.0", + ] as $relativePath) { + $candidate = data_get($parent, $relativePath); + + if ($this->isEmptySettingValue($candidate)) { + continue; + } + + if (is_array($candidate) && Arr::isList($candidate)) { + $listDeferred ??= $candidate; + + continue; + } + + return $candidate; + } + + if ($listDeferred !== null) { + return $listDeferred; + } + } + + return null; + } + + /** + * @param array $data + */ + protected function walkPathRaw(array $data, string $key): mixed + { + if ($key === '') { + return $data; + } + + $cursor = $data; + + foreach (explode('.', $key) as $segment) { + if (! is_array($cursor) || ! array_key_exists($segment, $cursor)) { + return null; + } + + $cursor = $cursor[$segment]; + } + + return $cursor; + } + + /** + * @param list $segments + */ + protected function isExpandableListResult(mixed $value, array $segments): bool + { + return count($segments) >= 2 + && is_array($value) + && Arr::isList($value); + } + + protected function isEmptySettingValue(mixed $value): bool + { + return $value === null || $value === '' || $value === []; + } + + protected function resolveTranslatedValue(mixed $value, string $locale): mixed + { + if (! is_array($value)) { + return $value; + } + + if ($this->looksLikeLocaleMap($value)) { + $fallback = (string) modularousConfig('fallback_locale', config('app.fallback_locale', 'en')); + + return $value[$locale] + ?? $value[$fallback] + ?? $value['*'] + ?? reset($value); + } + + return $value; + } + + /** + * @param array $tree + * @return array + */ + protected function resolveTranslatedTree(array $tree, string $locale): array + { + $resolved = []; + + foreach ($tree as $key => $value) { + if (is_array($value) && ! $this->looksLikeLocaleMap($value) && Arr::isAssoc($value)) { + $resolved[$key] = $this->resolveTranslatedTree($value, $locale); + + continue; + } + + $resolved[$key] = $this->resolveTranslatedValue($value, $locale); + } + + return $resolved; + } + + /** + * @param array $value + */ + protected function looksLikeLocaleMap(array $value): bool + { + if ($value === [] || Arr::isList($value)) { + return false; + } + + foreach (array_keys($value) as $key) { + if (! is_string($key) || ! $this->looksLikeLocaleKey($key)) { + return false; + } + } + + return true; + } + + protected function looksLikeLocaleKey(string $key): bool + { + if ($key === '*') { + return true; + } + + return (bool) preg_match('/^[a-z]{2}([_-][A-Za-z]{2,8})?$/', $key); + } +} From 2575f12beb59a009208977b766e4a59e3214e5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:29 +0300 Subject: [PATCH 20/26] feat(Favicons): update favicon handling to prioritize SystemSettings for dynamic favicon URLs, enhancing site customization options --- resources/views/partials/favicons.blade.php | 26 ++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/resources/views/partials/favicons.blade.php b/resources/views/partials/favicons.blade.php index f9e79ebfe..c918891e3 100644 --- a/resources/views/partials/favicons.blade.php +++ b/resources/views/partials/favicons.blade.php @@ -4,14 +4,24 @@ $faviconVersion = fn (string $path) => file_exists(public_path($path)) ? asset($path) . '?v=' . filemtime(public_path($path)) : asset($path); + + // Panel/admin always uses SystemSettings (not SiteSettings / CmsSettings). + $dbFavicon = SystemSettings::value('site.favicon.original'); @endphp - - - - -@if (file_exists(public_path('favicon.svg'))) - +@if (! empty($dbFavicon)) + + + + +@else + + + + + @if (file_exists(public_path('favicon.svg'))) + + @endif + + @endif - - From a9d0b55421275b54971089bc3e8ab787e4fab859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:36 +0300 Subject: [PATCH 21/26] feat(ManageTraits): enhance hasTranslatedInput method to support nested schema arrays for improved input handling --- src/Traits/ManageTraits.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Traits/ManageTraits.php b/src/Traits/ManageTraits.php index 461638d13..24448cea5 100755 --- a/src/Traits/ManageTraits.php +++ b/src/Traits/ManageTraits.php @@ -37,6 +37,12 @@ public function hasTranslatedInput($schema = []) $hasTranslated = true; break; + } elseif (isset($input['schema']) && is_array($input['schema'])) { + $hasTranslated = $this->hasTranslatedInput($input['schema']); + + if ($hasTranslated) { + break; + } } } From 864e5577105dfd678bbef72f9fe2f489b259dcfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:45 +0300 Subject: [PATCH 22/26] feat(SiteSeo): remove legacy robots.txt input and update UI to direct users to System Settings for SEO management --- vue/src/js/Pages/SiteSeo.vue | 79 ++++++------------------------------ 1 file changed, 13 insertions(+), 66 deletions(-) diff --git a/vue/src/js/Pages/SiteSeo.vue b/vue/src/js/Pages/SiteSeo.vue index 244bb567d..366d77850 100644 --- a/vue/src/js/Pages/SiteSeo.vue +++ b/vue/src/js/Pages/SiteSeo.vue @@ -16,16 +16,6 @@ - - {{ $t('messages.site_seo_db_disabled', 'Database-backed site SEO is disabled (MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS=false). The editor shows the env fallback; saving is ignored until this is enabled.') }} - - - - - +

+ {{ $t('messages.site_seo_robots_moved', 'Global robots.txt is now managed under System Settings → General (SEO section).') }} +

- {{ $t('messages.save', 'Save') }} + {{ $t('messages.open_system_settings', 'Open System Settings') }} -
+