diff --git a/README.md b/README.md index 4ea52a56..2c65ee69 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The command will also give you the opportunity to indicate whether you'd like ex If you originally opt-out of importing existing content, then later change your mind, you can import existing content by running the relevant commands: +- Addon Settings: `php please eloquent:import-addon-settings` - Assets: `php please eloquent:import-assets` - Blueprints and Fieldsets: `php please eloquent:import-blueprints` - Collections: `php please eloquent:import-collections` @@ -47,6 +48,7 @@ If your assets are being driven by the Eloquent Driver and you're managing your If you wish to move back to flat-files, you may use the following commands to export your content out of the database: +- Addon Settings: `php please eloquent:export-addon-settings` - Assets: `php please eloquent:export-assets` - Blueprints and Fieldsets: `php please eloquent:export-blueprints` - Collections: `php please eloquent:export-collections` diff --git a/config/eloquent-driver.php b/config/eloquent-driver.php index 0ba4e51e..a59ea0c4 100644 --- a/config/eloquent-driver.php +++ b/config/eloquent-driver.php @@ -5,6 +5,11 @@ 'connection' => env('STATAMIC_ELOQUENT_CONNECTION', ''), 'table_prefix' => env('STATAMIC_ELOQUENT_PREFIX', ''), + 'addon_settings' => [ + 'driver' => 'file', + 'model' => \Statamic\Eloquent\AddonSettings\AddonSettingsModel::class, + ], + 'asset_containers' => [ 'driver' => 'file', 'model' => \Statamic\Eloquent\Assets\AssetContainerModel::class, diff --git a/database/migrations/2025_07_07_100000_create_addon_settings_table.php b/database/migrations/2025_07_07_100000_create_addon_settings_table.php new file mode 100644 index 00000000..7a2555bb --- /dev/null +++ b/database/migrations/2025_07_07_100000_create_addon_settings_table.php @@ -0,0 +1,21 @@ +prefix('addon_settings'), function (Blueprint $table) { + $table->string('addon')->index()->primary(); + $table->json('settings')->nullable(); + }); + } + + public function down() + { + Schema::dropIfExists($this->prefix('addon_settings')); + } +}; diff --git a/src/AddonSettings/AddonSettings.php b/src/AddonSettings/AddonSettings.php new file mode 100644 index 00000000..c3fcf31b --- /dev/null +++ b/src/AddonSettings/AddonSettings.php @@ -0,0 +1,44 @@ +addon); + + return (new static($addon, $model->settings))->model($model); + } + + public function toModel() + { + return self::makeModelFromContract($this); + } + + public static function makeModelFromContract(AbstractSettings $settings) + { + $class = app('statamic.eloquent.addon_settings.model'); + + return $class::firstOrNew(['addon' => $settings->addon()->id()])->fill([ + 'settings' => array_filter($settings->raw()), + ]); + } + + public function model($model = null) + { + if (func_num_args() === 0) { + return $this->model; + } + + $this->model = $model; + + return $this; + } +} diff --git a/src/AddonSettings/AddonSettingsModel.php b/src/AddonSettings/AddonSettingsModel.php new file mode 100644 index 00000000..f0081dba --- /dev/null +++ b/src/AddonSettings/AddonSettingsModel.php @@ -0,0 +1,25 @@ + 'array', + ]; + } +} diff --git a/src/AddonSettings/AddonSettingsRepository.php b/src/AddonSettings/AddonSettingsRepository.php new file mode 100644 index 00000000..a645c76c --- /dev/null +++ b/src/AddonSettings/AddonSettingsRepository.php @@ -0,0 +1,37 @@ +toModel()->save(); + } + + public function delete(AddonSettingsContract $settings): bool + { + return $settings->toModel()->delete(); + } + + public static function bindings(): array + { + return [ + AddonSettingsContract::class => AddonSettings::class, + ]; + } +} diff --git a/src/Commands/ExportAddonSettings.php b/src/Commands/ExportAddonSettings.php new file mode 100644 index 00000000..3bd88be3 --- /dev/null +++ b/src/Commands/ExportAddonSettings.php @@ -0,0 +1,49 @@ +each(function ($model) { + Addon::get($model->addon)?->settings()->set($model->settings)->save(); + }); + + $this->newLine(); + $this->info('Addon settings exported'); + + return 0; + } +} diff --git a/src/Commands/ImportAddonSettings.php b/src/Commands/ImportAddonSettings.php new file mode 100644 index 00000000..72af0508 --- /dev/null +++ b/src/Commands/ImportAddonSettings.php @@ -0,0 +1,50 @@ +filter(fn ($addon) => collect($addon->settings()->raw())->filter()->isNotEmpty()) + ->each(function ($addon) { + app('statamic.eloquent.addon_settings.model')::updateOrCreate( + ['addon' => $addon->id()], + ['settings' => $addon->settings()->raw()] + ); + }); + + $this->components->info('Addon settings imported successfully.'); + + return 0; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index a3bc935b..51f1c691 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Console\AboutCommand; use Statamic\Assets\AssetContainerContents; +use Statamic\Contracts\Addons\SettingsRepository as AddonSettingsRepositoryContract; use Statamic\Contracts\Assets\AssetContainerRepository as AssetContainerRepositoryContract; use Statamic\Contracts\Assets\AssetRepository as AssetRepositoryContract; use Statamic\Contracts\Entries\CollectionRepository as CollectionRepositoryContract; @@ -19,6 +20,7 @@ use Statamic\Contracts\Taxonomies\TaxonomyRepository as TaxonomyRepositoryContract; use Statamic\Contracts\Taxonomies\TermRepository as TermRepositoryContract; use Statamic\Contracts\Tokens\TokenRepository as TokenRepositoryContract; +use Statamic\Eloquent\AddonSettings\AddonSettingsRepository; use Statamic\Eloquent\Assets\AssetContainerContents as EloquentAssetContainerContents; use Statamic\Eloquent\Assets\AssetContainerRepository; use Statamic\Eloquent\Assets\AssetQueryBuilder; @@ -171,6 +173,10 @@ private function publishMigrations(): void __DIR__.'/../database/migrations/2024_07_16_100000_create_sites_table.php' => database_path('migrations/2024_07_16_100000_create_sites_table.php'), ], 'statamic-eloquent-site-migrations'); + $this->publishes($addonSettingMigrations = [ + __DIR__.'/../database/migrations/2025_07_07_100000_create_addon_settings_table.php' => database_path('migrations/2025_07_07_100000_create_addon_settings_table.php'), + ], 'statamic-eloquent-addon-setting-migrations'); + $this->publishes( array_merge( $taxonomyMigrations, @@ -189,6 +195,7 @@ private function publishMigrations(): void $revisionMigrations, $tokenMigrations, $siteMigrations, + $addonSettingMigrations ), 'migrations' ); @@ -204,6 +211,7 @@ private function publishMigrations(): void public function register() { + $this->registerAddonSettings(); $this->registerAssetContainers(); $this->registerAssets(); $this->registerBlueprints(); @@ -224,6 +232,19 @@ public function register() $this->registerSites(); } + private function registerAddonSettings() + { + if (config('statamic.eloquent-driver.addon_settings.driver', 'file') != 'eloquent') { + return; + } + + $this->app->bind('statamic.eloquent.addon_settings.model', function () { + return config('statamic.eloquent-driver.addon_settings.model'); + }); + + Statamic::repository(AddonSettingsRepositoryContract::class, AddonSettingsRepository::class); + } + private function registerAssetContainers() { // if we have this config key then we started on 2.1.0 or earlier when @@ -549,6 +570,7 @@ protected function addAboutCommandInfo() } AboutCommand::add('Statamic Eloquent Driver', collect([ + 'Addon Settings' => config('statamic.eloquent-driver.addon_settings.driver', 'file'), 'Asset Containers' => config('statamic.eloquent-driver.asset_containers.driver', 'file'), 'Assets' => config('statamic.eloquent-driver.assets.driver', 'file'), 'Blueprints' => config('statamic.eloquent-driver.blueprints.driver', 'file'), diff --git a/tests/Commands/ExportAddonSettingsTest.php b/tests/Commands/ExportAddonSettingsTest.php new file mode 100644 index 00000000..3ea99fb7 --- /dev/null +++ b/tests/Commands/ExportAddonSettingsTest.php @@ -0,0 +1,67 @@ +makeFromPackage(['id' => 'statamic/seo-pro', 'slug' => 'seo-pro']); + $importer = $this->makeFromPackage(['id' => 'statamic/importer', 'slug' => 'importer']); + + Facades\Addon::shouldReceive('all')->andReturn(collect([$seoPro, $importer])); + Facades\Addon::shouldReceive('get')->with('statamic/seo-pro')->andReturn($seoPro); + Facades\Addon::shouldReceive('get')->with('statamic/importer')->andReturn($importer); + + AddonSettingsModel::create(['addon' => 'statamic/seo-pro', 'settings' => ['title' => 'SEO Title', 'description' => 'SEO Description']]); + AddonSettingsModel::create(['addon' => 'statamic/importer', 'settings' => ['chunk_size' => 100]]); + + $this->artisan('statamic:eloquent:export-addon-settings') + ->expectsOutputToContain('Addon settings exported') + ->assertExitCode(0); + + $this->assertFileExists(resource_path('addons/seo-pro.yaml')); + $this->assertEquals(<<<'YAML' +title: 'SEO Title' +description: 'SEO Description' + +YAML + , File::get(resource_path('addons/seo-pro.yaml'))); + + $this->assertFileExists(resource_path('addons/importer.yaml')); + $this->assertEquals(<<<'YAML' +chunk_size: 100 + +YAML + , File::get(resource_path('addons/importer.yaml'))); + } + + private function makeFromPackage($attributes = []) + { + return Addon::makeFromPackage(array_merge([ + 'id' => 'vendor/test-addon', + 'name' => 'Test Addon', + 'description' => 'Test description', + 'namespace' => 'Vendor\\TestAddon', + 'provider' => TestAddonServiceProvider::class, + 'autoload' => '', + 'url' => 'http://test-url.com', + 'developer' => 'Test Developer LLC', + 'developerUrl' => 'http://test-developer.com', + 'version' => '1.0', + 'editions' => ['foo', 'bar'], + ], $attributes)); + } +} diff --git a/tests/Commands/ImportAddonSettingsTest.php b/tests/Commands/ImportAddonSettingsTest.php new file mode 100644 index 00000000..fa423e82 --- /dev/null +++ b/tests/Commands/ImportAddonSettingsTest.php @@ -0,0 +1,113 @@ +app->bind(SettingsContract::class, FileSettings::class); + $this->app->bind(SettingsRepositoryContract::class, FileSettingsRepository::class); + + $this->app->bind('statamic.eloquent.addon_settings.model', function () { + return AddonSettingsModel::class; + }); + + $this->app['files']->deleteDirectory(resource_path('addons')); + } + + #[Test] + public function it_imports_addon_settings() + { + $this->assertCount(0, AddonSettingsModel::all()); + + $seoPro = $this->makeFromPackage(['id' => 'statamic/seo-pro']); + Facades\Addon::shouldReceive('get')->with('statamic/seo-pro')->andReturn($seoPro); + app(SettingsRepositoryContract::class)->make($seoPro, ['title' => 'SEO Title', 'description' => 'SEO Description'])->save(); + + $importer = $this->makeFromPackage(['id' => 'statamic/importer']); + Facades\Addon::shouldReceive('get')->with('statamic/importer')->andReturn($importer); + app(SettingsRepositoryContract::class)->make($importer, ['chunk_size' => 100])->save(); + + Facades\Addon::shouldReceive('all')->andReturn(collect([$seoPro, $importer])); + + $this->artisan('statamic:eloquent:import-addon-settings') + ->expectsOutputToContain('Addon settings imported successfully.') + ->assertExitCode(0); + + $this->assertCount(2, AddonSettingsModel::all()); + + $this->assertDatabaseHas('addon_settings', [ + 'addon' => 'statamic/seo-pro', + 'settings' => json_encode(['title' => 'SEO Title', 'description' => 'SEO Description']), + ]); + + $this->assertDatabaseHas('addon_settings', [ + 'addon' => 'statamic/importer', + 'settings' => json_encode(['chunk_size' => 100]), + ]); + } + + #[Test] + public function it_doesnt_import_addons_without_settings() + { + $this->assertCount(0, AddonSettingsModel::all()); + + $seoPro = $this->makeFromPackage(['id' => 'statamic/seo-pro']); + Facades\Addon::shouldReceive('get')->with('statamic/seo-pro')->andReturn($seoPro); + app(SettingsRepositoryContract::class)->make($seoPro, ['title' => 'SEO Title', 'description' => 'SEO Description'])->save(); + + $importer = $this->makeFromPackage(['id' => 'statamic/importer']); + Facades\Addon::shouldReceive('get')->with('statamic/importer')->andReturn($importer); + + Facades\Addon::shouldReceive('all')->andReturn(collect([$seoPro, $importer])); + + $this->artisan('statamic:eloquent:import-addon-settings') + ->expectsOutputToContain('Addon settings imported successfully.') + ->assertExitCode(0); + + $this->assertCount(1, AddonSettingsModel::all()); + + $this->assertDatabaseHas('addon_settings', [ + 'addon' => 'statamic/seo-pro', + 'settings' => json_encode(['title' => 'SEO Title', 'description' => 'SEO Description']), + ]); + + $this->assertDatabaseMissing('addon_settings', [ + 'addon' => 'statamic/importer', + ]); + } + + private function makeFromPackage($attributes = []) + { + return Addon::makeFromPackage(array_merge([ + 'id' => 'vendor/test-addon', + 'name' => 'Test Addon', + 'description' => 'Test description', + 'namespace' => 'Vendor\\TestAddon', + 'provider' => TestAddonServiceProvider::class, + 'autoload' => '', + 'url' => 'http://test-url.com', + 'developer' => 'Test Developer LLC', + 'developerUrl' => 'http://test-developer.com', + 'version' => '1.0', + 'editions' => ['foo', 'bar'], + ], $attributes)); + } +} diff --git a/tests/Repositories/AddonSettingsRepositoryTest.php b/tests/Repositories/AddonSettingsRepositoryTest.php new file mode 100644 index 00000000..27996102 --- /dev/null +++ b/tests/Repositories/AddonSettingsRepositoryTest.php @@ -0,0 +1,95 @@ +repo = new AddonSettingsRepository; + } + + #[Test] + public function it_gets_addon_settings() + { + $addon = $this->makeFromPackage(); + + Facades\Addon::shouldReceive('all')->andReturn(collect([$addon])); + Facades\Addon::shouldReceive('get')->with('vendor/test-addon')->andReturn($addon); + + AddonSettingsModel::create(['addon' => 'vendor/test-addon', 'settings' => ['foo' => 'bar', 'baz' => 'qux']]); + + $settings = $this->repo->find('vendor/test-addon'); + + $this->assertInstanceOf(AddonSettings::class, $settings); + $this->assertEquals($addon, $settings->addon()); + $this->assertEquals(['foo' => 'bar', 'baz' => 'qux'], $settings->all()); + } + + #[Test] + public function it_saves_addon_settings() + { + $addon = $this->makeFromPackage(); + + $settings = $this->repo->make($addon, [ + 'foo' => 'bar', + 'baz' => 'qux', + 'quux' => null, // Should be filtered out. + ]); + + $settings->save(); + + $this->assertDatabaseHas('addon_settings', [ + 'addon' => 'vendor/test-addon', + 'settings' => json_encode(['foo' => 'bar', 'baz' => 'qux']), + ]); + } + + #[Test] + public function it_deletes_addon_settings() + { + $addon = $this->makeFromPackage(); + + Facades\Addon::shouldReceive('all')->andReturn(collect([$addon])); + Facades\Addon::shouldReceive('get')->with('vendor/test-addon')->andReturn($addon); + + AddonSettingsModel::create(['addon' => 'vendor/test-addon', 'settings' => ['foo' => 'bar', 'baz' => 'qux']]); + + $this->repo->find('vendor/test-addon')->delete(); + + $this->assertDatabaseMissing('addon_settings', [ + 'addon' => 'vendor/test-addon', + ]); + } + + private function makeFromPackage($attributes = []) + { + return Addon::makeFromPackage(array_merge([ + 'id' => 'vendor/test-addon', + 'name' => 'Test Addon', + 'description' => 'Test description', + 'namespace' => 'Vendor\\TestAddon', + 'provider' => TestAddonServiceProvider::class, + 'autoload' => '', + 'url' => 'http://test-url.com', + 'developer' => 'Test Developer LLC', + 'developerUrl' => 'http://test-developer.com', + 'version' => '1.0', + 'editions' => ['foo', 'bar'], + ], $attributes)); + } +}