diff --git a/config/git.php b/config/git.php index 831ccb42f3d..7c569b0435f 100644 --- a/config/git.php +++ b/config/git.php @@ -97,6 +97,7 @@ 'paths' => [ base_path('content'), base_path('users'), + resource_path('addons'), resource_path('blueprints'), resource_path('fieldsets'), resource_path('forms'), diff --git a/resources/js/bootstrap/components.js b/resources/js/bootstrap/components.js index 04775d227b6..075311aa6b5 100644 --- a/resources/js/bootstrap/components.js +++ b/resources/js/bootstrap/components.js @@ -12,8 +12,7 @@ import EntryListing from '../components/entries/Listing.vue'; import CollectionListing from '../components/collections/Listing.vue'; import TaxonomyListing from '../components/taxonomies/Listing.vue'; import TermListing from '../components/terms/Listing.vue'; -import AddonList from '../components/AddonList.vue'; -import AddonDetails from '../components/AddonDetails.vue'; +import AddonListing from '../components/addons/Listing.vue'; import CollectionWidget from '../components/entries/CollectionWidget.vue'; import FormWidget from '../components/forms/FormWidget.vue'; import SvgIcon from '../components/SvgIcon.vue'; @@ -63,8 +62,7 @@ export default function registerGlobalComponents(app) { app.component('collection-list', CollectionListing); app.component('taxonomy-list', TaxonomyListing); app.component('term-list', TermListing); - app.component('addon-list', AddonList); - app.component('addon-details', AddonDetails); + app.component('addon-list', AddonListing); // Widgets app.component('collection-widget', CollectionWidget); diff --git a/resources/js/components/AddonDetails.vue b/resources/js/components/AddonDetails.vue deleted file mode 100644 index 8edea6d29cb..00000000000 --- a/resources/js/components/AddonDetails.vue +++ /dev/null @@ -1,137 +0,0 @@ - - - diff --git a/resources/js/components/AddonList.vue b/resources/js/components/AddonList.vue deleted file mode 100644 index 1fa52d50d76..00000000000 --- a/resources/js/components/AddonList.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - diff --git a/resources/js/components/addons/Editions.vue b/resources/js/components/addons/Editions.vue deleted file mode 100644 index 1f5c5d9e2c7..00000000000 --- a/resources/js/components/addons/Editions.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - diff --git a/resources/js/components/addons/Listing.vue b/resources/js/components/addons/Listing.vue new file mode 100644 index 00000000000..70daf78b7b6 --- /dev/null +++ b/resources/js/components/addons/Listing.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/lang/en/permissions.php b/resources/lang/en/permissions.php index e84f8db8121..ca67e5b6b5e 100644 --- a/resources/lang/en/permissions.php +++ b/resources/lang/en/permissions.php @@ -12,8 +12,6 @@ 'configure_sites_desc' => 'Ability to configure sites when multi-site is enabled.', 'configure_fields' => 'Configure Fields', 'configure_fields_desc' => 'Ability to edit blueprints, fieldsets, and their fields.', - 'configure_addons' => 'Configure Addons', - 'configure_addons_desc' => 'Ability to access the addon area to install and uninstall addons.', 'manage_preferences' => 'Manage Preferences', 'manage_preferences_desc' => 'Ability to customize global and role-specific preferences.', @@ -89,6 +87,11 @@ 'group_updates' => 'Updates', 'view_updates' => 'View updates', + 'group_addons' => 'Addons', + 'configure_addons' => 'Configure addons', + 'configure_addons_desc' => 'Grants access to all addon related permissions', + 'edit_addon_settings' => ':addon settings', + 'group_utilities' => 'Utilities', 'access_utility' => ':title', 'access_utility_desc' => 'Grants access to the :title utility', diff --git a/resources/views/addons/index.blade.php b/resources/views/addons/index.blade.php index ad474469da6..06a27b30d88 100644 --- a/resources/views/addons/index.blade.php +++ b/resources/views/addons/index.blade.php @@ -6,5 +6,14 @@ @section('title', __('Addons')) @section('content') - + + + + + + + @endsection diff --git a/resources/views/addons/settings.blade.php b/resources/views/addons/settings.blade.php deleted file mode 100644 index 89d26b356ea..00000000000 --- a/resources/views/addons/settings.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -@php - use function Statamic\trans as __; -@endphp - -@extends('statamic::layout') -@section('title', __('Addon Settings')) - -@section('content') - - - -@endsection diff --git a/resources/views/updater/index.blade.php b/resources/views/updater/index.blade.php index dd57524209d..b1dde222ea3 100644 --- a/resources/views/updater/index.blade.php +++ b/resources/views/updater/index.blade.php @@ -72,21 +72,6 @@ @endif - - @if ($unlistedAddons->count()) - - - - @foreach ($unlistedAddons as $addon) - - {{ $addon->name() }} - {{ $addon->version() }} - - @endforeach - - - - @endif name('addons.index'); - Route::post('addons/editions', AddonEditionsController::class); + Route::get('addons/{addon}/settings', [AddonSettingsController::class, 'edit'])->name('addons.settings.edit'); + Route::patch('addons/{addon}/settings', [AddonSettingsController::class, 'update'])->name('addons.settings.update'); Route::post('forms/actions', [FormActionController::class, 'run'])->name('forms.actions.run'); Route::post('forms/actions/list', [FormActionController::class, 'bulkActions'])->name('forms.actions.bulk'); @@ -378,7 +378,6 @@ }); Route::group(['prefix' => 'api', 'as' => 'api.'], function () { - Route::resource('addons', AddonsApiController::class)->only('index'); Route::resource('templates', TemplatesController::class)->only('index'); }); diff --git a/src/Extend/Addon.php b/src/Addons/Addon.php similarity index 89% rename from src/Extend/Addon.php rename to src/Addons/Addon.php index 337c8782447..459d83baed2 100644 --- a/src/Extend/Addon.php +++ b/src/Addons/Addon.php @@ -1,11 +1,14 @@ handle()); } + public function hasSettingsBlueprint(): bool + { + return $this->settingsBlueprint() !== null; + } + + public function settingsBlueprint() + { + $binding = "statamic.addons.{$this->slug()}.settings_blueprint"; + + if (! app()->bound($binding)) { + return null; + } + + return Blueprint::make()->setContents(app($binding)); + } + + public function settings(): Settings + { + $repo = app(SettingsRepository::class); + + return $repo->find($this->id()) ?? $repo->make($this); + } + + public function settingsUrl() + { + if (! $this->hasSettingsBlueprint()) { + return null; + } + + return cp_route('addons.settings.edit', $this->slug()); + } + + public function updatesUrl() + { + if (! $this->existsOnMarketplace()) { + return null; + } + + return cp_route('updater.product', $this->marketplaceSlug()); + } + /** * Get addon changelog. * diff --git a/src/Extend/AddonRepository.php b/src/Addons/AddonRepository.php similarity index 97% rename from src/Extend/AddonRepository.php rename to src/Addons/AddonRepository.php index 35bb60f174c..0e7fe0a4367 100644 --- a/src/Extend/AddonRepository.php +++ b/src/Addons/AddonRepository.php @@ -1,6 +1,6 @@ addon()->slug()}.yaml"); + } + + public function fileData() + { + return $this->raw(); + } +} diff --git a/src/Addons/FileSettingsRepository.php b/src/Addons/FileSettingsRepository.php new file mode 100644 index 00000000000..1ec5df3b111 --- /dev/null +++ b/src/Addons/FileSettingsRepository.php @@ -0,0 +1,58 @@ +makeFromPath($path); + } + + public function save(AddonSettingsContract $settings): bool + { + File::ensureDirectoryExists(resource_path('addons')); + + File::put($settings->path(), $settings->fileContents()); + + return true; + } + + public function delete(AddonSettingsContract $settings): bool + { + File::delete($settings->path()); + + return true; + } + + private function makeFromPath(string $path): AddonSettingsContract + { + $yaml = YAML::file($path)->parse(); + + $addon = Facades\Addon::all()->first(fn ($addon) => $addon->slug() === basename($path, '.yaml')); + + return $this->make($addon, $yaml); + } + + public static function bindings(): array + { + return [ + AddonSettingsContract::class => FileSettings::class, + ]; + } +} diff --git a/src/Extend/Manifest.php b/src/Addons/Manifest.php similarity index 99% rename from src/Extend/Manifest.php rename to src/Addons/Manifest.php index f9e57664915..ee7f20bf70e 100644 --- a/src/Extend/Manifest.php +++ b/src/Addons/Manifest.php @@ -1,6 +1,6 @@ addon = $addon; + $this->settings = $this->resolveAntlers($settings); + $this->rawSettings = $settings; + } + + public function addon(): Addon + { + return $this->addon; + } + + public function all(): array + { + return $this->settings; + } + + public function raw(): array + { + return $this->rawSettings; + } + + public function get(string $key, $default = null) + { + return $this->settings[$key] ?? $default; + } + + public function set(string|array $key, mixed $value = null): self + { + return is_array($key) ? $this->setValues($key) : $this->setValue($key, $value); + } + + private function setValue(string $key, mixed $value): self + { + $this->rawSettings[$key] = $value; + $this->settings[$key] = $this->resolveAntlersValue($value); + + return $this; + } + + private function setValues(array $values): self + { + $this->rawSettings = $values; + $this->settings = $this->resolveAntlers($values); + + return $this; + } + + public function save(): bool + { + if (AddonSettingsSaving::dispatch($this) === false) { + return false; + } + + app(SettingsRepository::class)->save($this); + + AddonSettingsSaved::dispatch($this); + + return true; + } + + public function delete(): bool + { + return app(SettingsRepository::class)->delete($this); + } + + public function resolveAntlers($config) + { + return collect($config) + ->map(fn ($value) => $this->resolveAntlersValue($value)) + ->all(); + } + + protected function resolveAntlersValue($value) + { + if (is_array($value)) { + return collect($value) + ->map(fn ($element) => $this->resolveAntlersValue($element)) + ->all(); + } + + return (string) Antlers::parse($value, ['config' => config()->all()]); + } +} diff --git a/src/Addons/SettingsRepository.php b/src/Addons/SettingsRepository.php new file mode 100644 index 00000000000..a52d3c6837b --- /dev/null +++ b/src/Addons/SettingsRepository.php @@ -0,0 +1,14 @@ +makeWith(AddonSettingsContract::class, compact('addon', 'settings')); + } +} diff --git a/src/Auth/CorePermissions.php b/src/Auth/CorePermissions.php index 1931c25b8d3..2ae8000f060 100644 --- a/src/Auth/CorePermissions.php +++ b/src/Auth/CorePermissions.php @@ -2,6 +2,7 @@ namespace Statamic\Auth; +use Statamic\Facades\Addon; use Statamic\Facades\AssetContainer; use Statamic\Facades\Collection; use Statamic\Facades\Form; @@ -23,7 +24,6 @@ public function boot() $this->register('configure sites'); $this->register('configure fields'); $this->register('configure form fields'); - $this->register('configure addons'); $this->register('manage preferences'); }); @@ -63,6 +63,10 @@ public function boot() $this->registerForms(); }); + $this->group('addons', function () { + $this->registerAddons(); + }); + $this->group('utilities', function () { $this->registerUtilities(); }); @@ -215,6 +219,20 @@ protected function registerForms() }); } + protected function registerAddons() + { + $this->register('configure addons'); + + Addon::all() + ->filter->hasSettingsBlueprint() + ->each(function ($addon) { + Permission::register("edit {$addon->slug()} settings", function ($permission) use ($addon) { + return $permission + ->label(__('statamic::permissions.edit_addon_settings', ['addon' => __($addon->name())])); + }); + }); + } + protected function registerUtilities() { Utility::boot()->all()->each(function ($utility) { diff --git a/src/CP/Navigation/CoreNav.php b/src/CP/Navigation/CoreNav.php index 5469d026b73..3c8719b1401 100644 --- a/src/CP/Navigation/CoreNav.php +++ b/src/CP/Navigation/CoreNav.php @@ -9,6 +9,7 @@ use Statamic\Contracts\Globals\GlobalSet; use Statamic\Contracts\Structures\Nav as NavContract; use Statamic\Contracts\Taxonomies\Taxonomy; +use Statamic\Facades\Addon; use Statamic\Facades\AssetContainer as AssetContainerAPI; use Statamic\Facades\Collection as CollectionAPI; use Statamic\Facades\CP\Nav; @@ -259,10 +260,15 @@ protected function makeToolsSection() ->view('statamic::nav.updates') ->can('view updates'); - Nav::tools('Addons') - ->route('addons.index') - ->icon('addons') - ->can('configure addons'); + if (User::current()->can('configure addons')) { + Nav::tools('Addons') + ->route('addons.index') + ->icon('addons') + ->can('configure addons') + ->children(fn () => $this->makeAddonSettingsItems()); + } else { + $this->makeAddonSettingsItems(); + } if (Stache::duplicates()->isNotEmpty()) { Nav::tools('Duplicate IDs') @@ -285,6 +291,19 @@ protected function makeToolsSection() return $this; } + protected function makeAddonSettingsItems() + { + return Addon::all() + ->sortBy->name() + ->filter->hasSettingsBlueprint() + ->map(function ($addon) { + return Nav::tools($addon->name()) + ->url($addon->settingsUrl()) + ->icon('cog') + ->can('editSettings', $addon); + }); + } + /** * Make utilities items. * diff --git a/src/Console/Commands/AddonsDiscover.php b/src/Console/Commands/AddonsDiscover.php index 55304de2bd9..241884e441f 100644 --- a/src/Console/Commands/AddonsDiscover.php +++ b/src/Console/Commands/AddonsDiscover.php @@ -3,8 +3,8 @@ namespace Statamic\Console\Commands; use Illuminate\Console\Command; +use Statamic\Addons\Manifest; use Statamic\Console\RunsInPlease; -use Statamic\Extend\Manifest; class AddonsDiscover extends Command { diff --git a/src/Console/Commands/InstallEloquentDriver.php b/src/Console/Commands/InstallEloquentDriver.php index 368a39a7b8d..25257746285 100644 --- a/src/Console/Commands/InstallEloquentDriver.php +++ b/src/Console/Commands/InstallEloquentDriver.php @@ -127,6 +127,7 @@ protected function repositories(): array protected function allRepositories(): Collection { return collect([ + 'addon_settings' => 'Addon Settings', 'asset_containers' => 'Asset Containers', 'assets' => 'Assets', 'blueprints' => 'Blueprints', @@ -151,6 +152,9 @@ protected function allRepositories(): Collection protected function repositoryHasBeenMigrated(string $repository): bool { switch ($repository) { + case 'addon_settings': + return config('statamic.system.addon_settings_driver') === 'database'; + case 'asset_containers': return config('statamic.eloquent-driver.asset_containers.driver') === 'eloquent'; @@ -208,6 +212,30 @@ protected function repositoryHasBeenMigrated(string $repository): bool } } + protected function migrateAddonSettings(): void + { + spin( + callback: function () { + $this->runArtisanCommand('vendor:publish --tag=statamic-eloquent-addon-setting-migrations'); + $this->runArtisanCommand('migrate'); + + $this->switchToEloquentDriver('addon_settings'); + }, + message: 'Migrating addon settings...' + ); + + $this->infoMessage('Configured addon settings'); + + if ($this->shouldImport('addon settings')) { + spin( + callback: fn () => $this->runArtisanCommand('statamic:eloquent:import-addon-settings'), + message: 'Importing existing addon settings...' + ); + + $this->infoMessage('Imported existing addon settings'); + } + } + protected function migrateAssetContainers(): void { spin( @@ -662,9 +690,25 @@ private function infoMessage(string $message): void private function switchToEloquentDriver(string $repository): void { + $config = Str::of(File::get(config_path('statamic/eloquent-driver.php'))); + + if (! $config->contains("'{$repository}' => [")) { + $baseConfig = File::get(base_path('vendor/statamic/eloquent-driver/config/eloquent-driver.php')); + + $matches = []; + preg_match("/'{$repository}' => \[(.*?)],/s", $baseConfig, $matches); + + $repositoryInBaseConfig = $matches[0] ?? null; + + $config = $config->replace( + '];', + PHP_EOL.' '.$repositoryInBaseConfig.PHP_EOL.'];' + ); + } + File::put( config_path('statamic/eloquent-driver.php'), - Str::of(File::get(config_path('statamic/eloquent-driver.php'))) + $config ->replace( "'{$repository}' => [\n 'driver' => 'file'", "'{$repository}' => [\n 'driver' => 'eloquent'" diff --git a/src/Contracts/Addons/Settings.php b/src/Contracts/Addons/Settings.php new file mode 100644 index 00000000000..353d1687d5d --- /dev/null +++ b/src/Contracts/Addons/Settings.php @@ -0,0 +1,22 @@ +authorize('configure addons'); - - $withInstalled = $request->installed ?? false; - - $addons = (new AddonsQuery) - ->search($request->search) - ->page($request->page) - ->installed($withInstalled) - ->paginate(); - - $resource = JsonResource::collection($addons); - - if ($withInstalled) { - $resource->additional(['unlisted' => $this->unlisted()]); - } - - return $resource; - } - - protected function unlisted() - { - return Addon::all()->reject->existsOnMarketplace()->map(function ($addon) { - return [ - 'name' => $addon->name(), - 'package' => $addon->package(), - ]; - })->values()->all(); - } -} diff --git a/src/Http/Controllers/CP/AddonEditionsController.php b/src/Http/Controllers/CP/AddonEditionsController.php deleted file mode 100644 index 1a4c9009c9f..00000000000 --- a/src/Http/Controllers/CP/AddonEditionsController.php +++ /dev/null @@ -1,30 +0,0 @@ -middleware(\Illuminate\Auth\Middleware\Authorize::class.':configure addons'); - } - - public function __invoke(Request $request) - { - $request->validate([ - 'addon' => 'required', - 'edition' => 'required', - ]); - - $config = config('statamic.editions'); - $config['addons'][$request->addon] = $request->edition; - - $str = 'first(fn ($a) => $a->slug() === $addon); + + if (! $addon || ! $addon->hasSettingsBlueprint()) { + throw new NotFoundHttpException; + } + + $this->authorize('editSettings', $addon); + + return PublishForm::make($addon->settingsBlueprint()) + ->asConfig() + ->icon('cog') + ->title($addon->name()) + ->values($addon->settings()->raw()) + ->submittingTo(cp_route('addons.settings.update', $addon->slug())); + } + + public function update(Request $request, $addon) + { + /** @var \Statamic\Addons\Addon $addon */ + $addon = Addon::all()->first(fn ($a) => $a->slug() === $addon); + + if (! $addon || ! $addon->hasSettingsBlueprint()) { + throw new NotFoundHttpException; + } + + $this->authorize('editSettings', $addon); + + $values = PublishForm::make($addon->settingsBlueprint())->submit($request->all()); + + $saved = $addon->settings()->set($values)->save(); + + return ['saved' => $saved]; + } +} diff --git a/src/Http/Controllers/CP/Addons/AddonsController.php b/src/Http/Controllers/CP/Addons/AddonsController.php new file mode 100644 index 00000000000..661712985bb --- /dev/null +++ b/src/Http/Controllers/CP/Addons/AddonsController.php @@ -0,0 +1,34 @@ +authorize('index', Addon::class); + + return view('statamic::addons.index', [ + 'addons' => Facades\Addon::all()->map(fn (Addon $addon) => [ + 'name' => $addon->name(), + 'version' => $addon->version(), + 'developer' => $addon->developer() ?? $addon->marketplaceSellerSlug(), + 'description' => $addon->description(), + 'marketplace_url' => $addon->marketplaceUrl(), + 'updates_url' => Facades\User::current()->can('view updates') ? $addon->updatesUrl() : null, + 'settings_url' => Facades\User::current()->can('editSettings', $addon) ? $addon->settingsUrl() : null, + ])->values()->all(), + 'columns' => [ + Column::make('name'), + Column::make('developer'), + Column::make('description'), + Column::make('version'), + ], + ]); + } +} diff --git a/src/Http/Controllers/CP/AddonsController.php b/src/Http/Controllers/CP/AddonsController.php deleted file mode 100644 index b74ba81dac8..00000000000 --- a/src/Http/Controllers/CP/AddonsController.php +++ /dev/null @@ -1,21 +0,0 @@ -middleware(\Illuminate\Auth\Middleware\Authorize::class.':configure addons'); - } - - public function index() - { - return view('statamic::addons.index', [ - 'title' => __('Addons'), - 'addonCount' => Addon::all()->count(), - ]); - } -} diff --git a/src/Http/Controllers/CP/Updater/UpdaterController.php b/src/Http/Controllers/CP/Updater/UpdaterController.php index 2fbdba18bc9..0771d0cec5a 100644 --- a/src/Http/Controllers/CP/Updater/UpdaterController.php +++ b/src/Http/Controllers/CP/Updater/UpdaterController.php @@ -29,7 +29,6 @@ public function index(Licenses $licenses) 'requestError' => $licenses->requestFailed(), 'statamic' => Marketplace::statamic()->changelog(), 'addons' => $addons->filter->existsOnMarketplace(), - 'unlistedAddons' => $addons->reject->existsOnMarketplace(), ]); } diff --git a/src/Marketplace/AddonsQuery.php b/src/Marketplace/AddonsQuery.php deleted file mode 100644 index e88eadc8c97..00000000000 --- a/src/Marketplace/AddonsQuery.php +++ /dev/null @@ -1,86 +0,0 @@ -search = $search; - - return $this; - } - - public function page($page) - { - $this->page = $page; - - return $this; - } - - public function installed(bool $installed) - { - $this->installed = $installed; - - return $this; - } - - public function get() - { - $installed = $this->installedProducts(); - - $params = [ - 'page' => $this->page, - 'search' => $this->search, - 'filter' => ['statamic' => '3,4,5'], - 'sort' => 'most-popular', - 'perPage' => 12, - ]; - - if ($this->installed) { - if ($installed->isEmpty()) { - return ['data' => [], 'meta' => ['total' => 0, 'per_page' => 15]]; - } - - $params['filter']['products'] = $installed->join(','); - } - - $response = Client::get('addons', $params); - - $response['data'] = collect($response['data'])->map(function ($addon) use ($installed) { - return $addon + [ - 'installed' => $isInstalled = $installed->contains($addon['id']), - 'edition' => $isInstalled ? Addon::get($addon['package'])->edition() : null, - ]; - })->all(); - - return $response; - } - - public function paginate() - { - $response = $this->get(); - - return new LengthAwarePaginator( - $response['data'], - $response['meta']['total'], - $response['meta']['per_page'], - $this->page, - ['path' => Paginator::resolveCurrentPath()] - ); - } - - private function installedProducts() - { - return Addon::all()->map->marketplaceId()->filter(); - } -} diff --git a/src/Policies/AddonPolicy.php b/src/Policies/AddonPolicy.php new file mode 100644 index 00000000000..99944bde1a3 --- /dev/null +++ b/src/Policies/AddonPolicy.php @@ -0,0 +1,29 @@ +isSuper() || $user->hasPermission('configure addons')) { + return true; + } + } + + public function index($user) + { + // + } + + public function editSettings($user, $addon) + { + $user = User::fromUser($user); + + return $user->hasPermission("edit {$addon->package()} settings"); + } +} diff --git a/src/Providers/AddonServiceProvider.php b/src/Providers/AddonServiceProvider.php index bd2e65efb87..1aec1b6dba9 100644 --- a/src/Providers/AddonServiceProvider.php +++ b/src/Providers/AddonServiceProvider.php @@ -6,18 +6,20 @@ use Illuminate\Console\Command; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; use Illuminate\Support\Reflector; use Illuminate\Support\ServiceProvider; use Statamic\Actions\Action; +use Statamic\Addons\Manifest; use Statamic\Dictionaries\Dictionary; use Statamic\Exceptions\NotBootedException; -use Statamic\Extend\Manifest; use Statamic\Facades\Addon; use Statamic\Facades\Blueprint; use Statamic\Facades\Fieldset; use Statamic\Facades\Path; +use Statamic\Facades\YAML; use Statamic\Fields\Fieldtype; use Statamic\Forms\JsDrivers\JsDriver; use Statamic\Modifiers\Modifier; @@ -220,6 +222,7 @@ public function boot() ->bootBlueprints() ->bootFieldsets() ->bootPublishAfterInstall() + ->bootSettingsBlueprint() ->bootAddon(); $this->bootedAddons()->push($this->getAddon()->id()); @@ -801,6 +804,26 @@ protected function bootFieldsets() return $this; } + protected function registerSettingsBlueprint(array $blueprint): self + { + $this->app->bind("statamic.addons.{$this->getAddon()->slug()}.settings_blueprint", fn () => $blueprint); + + return $this; + } + + protected function bootSettingsBlueprint() + { + if (! $this->shouldBootRootItems()) { + return $this; + } + + if (file_exists($path = "{$this->getAddon()->directory()}resources/blueprints/settings.yaml")) { + $this->registerSettingsBlueprint(YAML::file($path)->parse()); + } + + return $this; + } + protected function autoloadFilesFromFolder($folder, $requiredClass = null) { try { diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index fed19853627..ae6698ded5a 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -132,6 +132,7 @@ public function register() \Statamic\Contracts\Forms\FormRepository::class => \Statamic\Forms\FormRepository::class, \Statamic\Contracts\Forms\SubmissionRepository::class => \Statamic\Stache\Repositories\SubmissionRepository::class, \Statamic\Contracts\Tokens\TokenRepository::class => \Statamic\Tokens\FileTokenRepository::class, + \Statamic\Contracts\Addons\SettingsRepository::class => \Statamic\Addons\FileSettingsRepository::class, ])->each(function ($concrete, $abstract) { if (! $this->app->bound($abstract)) { Statamic::repository($abstract, $concrete); diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php index 51b2fcd0931..9fba26fbca5 100755 --- a/src/Providers/AuthServiceProvider.php +++ b/src/Providers/AuthServiceProvider.php @@ -42,6 +42,7 @@ class AuthServiceProvider extends ServiceProvider \Statamic\Contracts\Assets\AssetContainer::class => Policies\AssetContainerPolicy::class, \Statamic\Fields\Fieldset::class => Policies\FieldsetPolicy::class, \Statamic\Sites\Site::class => Policies\SitePolicy::class, + \Statamic\Addons\Addon::class => Policies\AddonPolicy::class, ]; public function register() diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 17748c91a6d..529c1306f09 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -7,9 +7,9 @@ use Illuminate\Support\ServiceProvider; use Statamic\Actions; use Statamic\Actions\Action; +use Statamic\Addons\Manifest; use Statamic\Dictionaries; use Statamic\Dictionaries\Dictionary; -use Statamic\Extend\Manifest; use Statamic\Fields\Fieldtype; use Statamic\Fieldtypes; use Statamic\Forms\JsDrivers; @@ -252,6 +252,7 @@ class ExtensionServiceProvider extends ServiceProvider Updates\RemoveParentField::class, Updates\UpdateGlobalVariables::class, Updates\PublishMigrationForTwoFactorColumns::class, + Updates\AddAddonSettingsToGitConfig::class, ]; public function register() diff --git a/src/Testing/AddonTestCase.php b/src/Testing/AddonTestCase.php index 890960fe67d..874937e45fa 100644 --- a/src/Testing/AddonTestCase.php +++ b/src/Testing/AddonTestCase.php @@ -6,8 +6,8 @@ use Illuminate\Support\Str; use Orchestra\Testbench\TestCase as OrchestraTestCase; use ReflectionClass; +use Statamic\Addons\Manifest; use Statamic\Console\Processes\Composer; -use Statamic\Extend\Manifest; use Statamic\Providers\StatamicServiceProvider; use Statamic\Statamic; use Statamic\Testing\Concerns\PreventsSavingStacheItemsToDisk; diff --git a/src/UpdateScripts/AddAddonSettingsToGitConfig.php b/src/UpdateScripts/AddAddonSettingsToGitConfig.php new file mode 100644 index 00000000000..86a0dcba28d --- /dev/null +++ b/src/UpdateScripts/AddAddonSettingsToGitConfig.php @@ -0,0 +1,42 @@ +isUpdatingTo('6.0.0'); + } + + public function update() + { + if (! File::exists($path = config_path('statamic/git.php'))) { + return; + } + + $config = File::get($path); + + $addBelow = <<<'EOT' + base_path('users'), + resource_path('blueprints'), +EOT; + + $replacement = <<<'EOT' + base_path('users'), + resource_path('addons'), + resource_path('blueprints'), +EOT; + + if (Str::contains($config, $replacement) || ! Str::contains($config, $addBelow)) { + return; + } + + $config = str_replace($addBelow, $replacement, $config); + + File::put($path, $config); + } +} diff --git a/tests/Extend/AddonTest.php b/tests/Addons/AddonTest.php similarity index 74% rename from tests/Extend/AddonTest.php rename to tests/Addons/AddonTest.php index 1b05010798d..da60c84c00a 100644 --- a/tests/Extend/AddonTest.php +++ b/tests/Addons/AddonTest.php @@ -1,17 +1,23 @@ addonFixtureDir = Path::tidy(realpath(__DIR__.'/../Fixtures/Addon').'/'); + + $this->app['files']->deleteDirectory(resource_path('addons')); } #[Test] @@ -207,6 +216,77 @@ public function it_doesnt_allow_writing_files_if_no_provider_is_set() $this->fail('Exception was not thrown.'); } + #[Test] + public function it_checks_if_addon_has_settings_blueprint() + { + // It doesn't need to be a real blueprint, it just needs to be bound. + $this->app->bind('statamic.addons.test-addon.settings_blueprint', fn () => []); + + $this->assertTrue($this->makeFromPackage(['slug' => 'test-addon'])->hasSettingsBlueprint()); + $this->assertFalse($this->makeFromPackage(['slug' => 'another-addon'])->hasSettingsBlueprint()); + } + + #[Test] + public function it_gets_the_settings_blueprint() + { + $this->app->bind('statamic.addons.test-addon.settings_blueprint', fn () => [ + 'tabs' => [ + 'main' => [ + 'sections' => [ + [ + 'fields' => [ + [ + 'handle' => 'api_key', + 'field' => ['type' => 'text'], + ], + ], + ], + ], + ], + ], + ]); + + $blueprint = $this->makeFromPackage(['slug' => 'test-addon'])->settingsBlueprint(); + + $this->assertInstanceOf(Blueprint::class, $blueprint); + $this->assertTrue($blueprint->hasField('api_key')); + } + + #[Test] + public function it_gets_settings() + { + $addon = $this->makeFromPackage(); + Facades\Addon::shouldReceive('all')->andReturn(collect([$addon])); + Facades\Addon::shouldReceive('get')->with('vendor/test-addon')->andReturn($addon); + + app(SettingsRepository::class)->make($addon, [ + 'api_key' => '12345', + 'another_setting' => 'value', + ])->save(); + + $settings = $addon->settings(); + + $this->assertInstanceOf(Settings::class, $settings); + $this->assertEquals($addon, $settings->addon()); + $this->assertEquals([ + 'api_key' => '12345', + 'another_setting' => 'value', + ], $settings->all()); + } + + #[Test] + public function it_gets_settings_instance_even_if_no_settings_are_saved() + { + $addon = $this->makeFromPackage(['slug' => 'test-addon']); + Facades\Addon::shouldReceive('get')->with('vendor/test-addon')->andReturn($addon); + + $settings = $addon->settings(); + + $this->assertInstanceOf(Settings::class, $settings); + $this->assertEquals($addon, $settings->addon()); + $this->assertEquals([], $settings->all()); + } + #[Test] public function it_gets_the_name_from_id_if_it_wasnt_specified() { diff --git a/tests/Addons/FileSettingsRepositoryTest.php b/tests/Addons/FileSettingsRepositoryTest.php new file mode 100644 index 00000000000..9eaad3b50f5 --- /dev/null +++ b/tests/Addons/FileSettingsRepositoryTest.php @@ -0,0 +1,119 @@ +repository = new FileSettingsRepository; + + $this->app['files']->ensureDirectoryExists(resource_path('addons')); + } + + #[Test] + public function it_makes_an_addon_settings_instance() + { + $addon = $this->makeFromPackage(); + + $settings = $this->repository->make($addon, [ + 'foo' => 'bar', + 'baz' => 'qux', + ]); + + $this->assertInstanceOf(FileSettings::class, $settings); + $this->assertEquals($addon, $settings->addon()); + $this->assertEquals(['foo' => 'bar', 'baz' => 'qux'], $settings->all()); + } + + #[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); + + File::put(resource_path('addons/test-addon.yaml'), <<<'YAML' +foo: bar +baz: qux +YAML); + + $settings = $this->repository->find($addon->id()); + + $this->assertInstanceOf(FileSettings::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->repository->make($addon, [ + 'foo' => 'bar', + 'baz' => 'qux', + 'quux' => null, // Should be filtered out. + ]); + + $settings->save(); + + $this->assertFileExists(resource_path('addons/test-addon.yaml')); + + $this->assertEquals(File::get(resource_path('addons/test-addon.yaml')), <<<'YAML' +foo: bar +baz: qux + +YAML); + } + + #[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); + + File::put(resource_path('addons/test-addon.yaml'), ''); + + $settings = $this->repository->find($addon->id()); + + $settings->delete(); + + $this->assertFileDoesNotExist(resource_path('addons/test-addon.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/Addons/FileSettingsTest.php b/tests/Addons/FileSettingsTest.php new file mode 100644 index 00000000000..bbb2cf87c7c --- /dev/null +++ b/tests/Addons/FileSettingsTest.php @@ -0,0 +1,39 @@ +makeFromPackage(); + $settings = new FileSettings($addon); + $this->assertEquals(resource_path('addons/test-addon.yaml'), $settings->path()); + } + + 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/Addons/SettingsTest.php b/tests/Addons/SettingsTest.php new file mode 100644 index 00000000000..9f91f125e12 --- /dev/null +++ b/tests/Addons/SettingsTest.php @@ -0,0 +1,219 @@ +makeFromPackage(); + $settings = new Settings($addon, ['foo' => 'bar']); + + $this->assertEquals($addon, $settings->addon()); + } + + #[Test] + public function it_returns_the_values() + { + $addon = $this->makeFromPackage(); + $settings = new Settings($addon, [ + 'website_name' => '{{ config:app:url }}', + 'foo' => 'bar', + 'baz' => [ + 'qux' => '{{ config:app:name }}', + ], + ]); + + $this->assertIsArray($settings->all()); + $this->assertSame([ + 'website_name' => 'http://localhost', + 'foo' => 'bar', + 'baz' => [ + 'qux' => 'Laravel', + ], + ], $settings->all()); + } + + #[Test] + public function it_returns_the_raw_values() + { + $addon = $this->makeFromPackage(); + $settings = new Settings($addon, [ + 'website_name' => '{{ config:app:url }}', + 'foo' => 'bar', + 'baz' => [ + 'qux' => '{{ config:app:name }}', + ], + ]); + + $this->assertIsArray($settings->raw()); + $this->assertSame([ + 'website_name' => '{{ config:app:url }}', + 'foo' => 'bar', + 'baz' => [ + 'qux' => '{{ config:app:name }}', + ], + ], $settings->raw()); + } + + #[Test] + public function it_gets_a_value() + { + $addon = $this->makeFromPackage(); + $settings = new Settings($addon, ['foo' => 'bar']); + + $this->assertEquals('bar', $settings->get('foo')); + $this->assertNull($settings->get('nonexistent')); + $this->assertEquals('default', $settings->get('nonexistent', 'default')); + } + + #[Test] + public function it_sets_a_value() + { + config(['test' => ['a' => 'A', 'b' => 'B']]); + $addon = $this->makeFromPackage(); + $settings = new Settings($addon, ['foo' => 'bar']); + + $settings->set('baz', '{{ config:app:name }}'); + + $this->assertEquals(['foo' => 'bar', 'baz' => 'Laravel'], $settings->all()); + $this->assertEquals(['foo' => 'bar', 'baz' => '{{ config:app:name }}'], $settings->raw()); + + $settings->set('qux', $raw = [ + 'alfa' => 'bravo', + 'charlie' => '{{ config:test:a }}', + 'delta' => [ + 'echo' => '{{ config:test:b }}', + ], + ]); + $this->assertEquals([ + 'foo' => 'bar', + 'baz' => 'Laravel', + 'qux' => [ + 'alfa' => 'bravo', + 'charlie' => 'A', + 'delta' => ['echo' => 'B'], + ], + ], $settings->all()); + $this->assertEquals([ + 'foo' => 'bar', + 'baz' => '{{ config:app:name }}', + 'qux' => $raw, + ], $settings->raw()); + } + + #[Test] + public function it_sets_all_values() + { + config(['test' => ['a' => 'A', 'b' => 'B']]); + $addon = $this->makeFromPackage(); + $settings = new Settings($addon, ['foo' => 'bar']); + + $settings->set($raw = [ + 'alfa' => 'bravo', + 'charlie' => '{{ config:test:a }}', + 'delta' => [ + 'echo' => '{{ config:test:b }}', + ], + ]); + + $this->assertEquals([ + 'alfa' => 'bravo', + 'charlie' => 'A', + 'delta' => ['echo' => 'B'], + ], $settings->all()); + $this->assertEquals($raw, $settings->raw()); + } + + #[Test] + public function it_saves_settings() + { + Event::fake(); + + $addon = $this->makeFromPackage(); + $settings = new Settings($addon, ['website_name' => '{{ config:app:url }}', 'foo' => 'bar']); + + $this->mock(SettingsRepository::class, function ($mock) use ($settings) { + $mock->shouldReceive('save')->with($settings)->andReturn(true)->once(); + }); + + $return = $settings->save(); + + $this->assertTrue($return); + + Event::assertDispatched(AddonSettingsSaving::class); + Event::assertDispatched(AddonSettingsSaved::class); + } + + #[Test] + public function if_saving_event_returns_false_the_settings_dont_save() + { + Event::fake([AddonSettingsSaved::class]); + + Event::listen(AddonSettingsSaving::class, function () { + return false; + }); + + $addon = $this->makeFromPackage(); + $settings = new Settings($addon, ['website_name' => '{{ config:app:url }}', 'foo' => 'bar']); + + $this->mock(SettingsRepository::class, function ($mock) use ($settings) { + $mock->shouldReceive('save')->with($settings)->andReturn(true)->never(); + }); + + $return = $settings->save(); + + $this->assertFalse($return); + + Event::assertNotDispatched(AddonSettingsSaved::class); + } + + #[Test] + public function it_deletes_settings() + { + $addon = $this->makeFromPackage(); + $settings = new Settings($addon, ['website_name' => '{{ config:app:url }}', 'foo' => 'bar']); + + $this->mock(SettingsRepository::class, function ($mock) use ($settings) { + $mock->shouldReceive('delete')->with($settings)->andReturn(true)->once(); + }); + + $return = $settings->delete(); + + $this->assertTrue($return); + } + + 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)); + } +} + +class Settings extends AbstractSettings +{ +} diff --git a/tests/Composer/AddonChangelogTest.php b/tests/Composer/AddonChangelogTest.php index b087c22ae86..941c8f5ece3 100644 --- a/tests/Composer/AddonChangelogTest.php +++ b/tests/Composer/AddonChangelogTest.php @@ -5,7 +5,7 @@ use Facades\GuzzleHttp\Client; use Mockery; use PHPUnit\Framework\Attributes\Test; -use Statamic\Extend\Addon; +use Statamic\Addons\Addon; use Statamic\Updater\AddonChangelog; use Tests\TestCase; diff --git a/tests/Feature/Addons/EditAddonSettingsTest.php b/tests/Feature/Addons/EditAddonSettingsTest.php new file mode 100644 index 00000000000..56f0607ecba --- /dev/null +++ b/tests/Feature/Addons/EditAddonSettingsTest.php @@ -0,0 +1,129 @@ +makeFromPackage(['slug' => 'test-addon']); + + Facades\Addon::shouldReceive('all')->andReturn(collect([$addon])); + Facades\Addon::shouldReceive('get')->with('vendor/test-addon')->andReturn($addon); + + $this->app->bind('statamic.addons.test-addon.settings_blueprint', fn () => [ + 'tabs' => [ + 'main' => [ + 'sections' => [ + [ + 'fields' => [ + [ + 'handle' => 'api_key', + 'field' => ['type' => 'text'], + ], + ], + ], + ], + ], + ], + ]); + } + + #[Test] + public function can_edit_addon_settings() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->get(cp_route('addons.settings.edit', 'test-addon')) + ->assertOk() + ->assertSee('Test Addon'); + } + + #[Test] + public function can_edit_addon_settings_with_configure_addons_permission() + { + $this->setTestRoles(['test' => ['access cp', 'configure addons']]); + + $this + ->actingAs(User::make()->assignRole('test')->save()) + ->get(cp_route('addons.settings.edit', 'test-addon')) + ->assertOk() + ->assertSee('Test Addon'); + } + + #[Test] + public function can_edit_addon_settings_with_edit_addon_settings_permission() + { + $this->setTestRoles(['test' => ['access cp', 'edit vendor/test-addon settings']]); + + $this + ->actingAs(User::make()->assignRole('test')->save()) + ->get(cp_route('addons.settings.edit', 'test-addon')) + ->assertOk() + ->assertSee('Test Addon'); + } + + #[Test] + public function cant_edit_addon_settings_without_permission() + { + $this->setTestRoles(['test' => ['access cp']]); + + $this + ->actingAs(User::make()->save()) + ->get(cp_route('addons.settings.edit', 'test-addon')) + ->assertRedirect('/cp'); + } + + #[Test] + public function cant_edit_addon_settings_for_non_existent_addon() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->get(cp_route('addons.settings.edit', 'non-existent-addon')) + ->assertNotFound(); + } + + #[Test] + public function cant_edit_addon_settings_when_addon_doesnt_have_any_settings() + { + // Forget the settings blueprint from the container. + $this->app->offsetUnset('statamic.addons.test-addon.settings_blueprint'); + + $this + ->actingAs(User::make()->makeSuper()->save()) + ->get(cp_route('addons.settings.edit', 'test-addon')) + ->assertNotFound(); + } + + 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/Feature/Addons/UpdateAddonSettingsTest.php b/tests/Feature/Addons/UpdateAddonSettingsTest.php new file mode 100644 index 00000000000..da6ce8b03f7 --- /dev/null +++ b/tests/Feature/Addons/UpdateAddonSettingsTest.php @@ -0,0 +1,163 @@ +addon = $this->makeFromPackage(['slug' => 'test-addon']); + + Facades\Addon::shouldReceive('all')->andReturn(collect([$this->addon])); + Facades\Addon::shouldReceive('get')->with('vendor/test-addon')->andReturn($this->addon); + + $this->app->bind('statamic.addons.test-addon.settings_blueprint', fn () => [ + 'tabs' => [ + 'main' => [ + 'sections' => [ + [ + 'fields' => [ + [ + 'handle' => 'api_key', + 'field' => ['type' => 'text'], + ], + ], + ], + ], + ], + ], + ]); + } + + #[Test] + public function can_update_addon_settings() + { + $this->addon->settings()->set(['api_key' => 'original-api-key'])->save(); + + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patch(cp_route('addons.settings.edit', 'test-addon'), [ + 'api_key' => 'new-api-key', + ]) + ->assertOk() + ->assertJson(['saved' => true]); + + $this->assertEquals('new-api-key', $this->addon->settings()->get('api_key')); + } + + #[Test] + public function can_update_addon_settings_with_configure_addons_permission() + { + $this->addon->settings()->set(['api_key' => 'original-api-key'])->save(); + + $this->setTestRoles(['test' => ['access cp', 'configure addons']]); + + $this + ->actingAs(User::make()->assignRole('test')->save()) + ->patch(cp_route('addons.settings.edit', 'test-addon'), [ + 'api_key' => 'new-api-key', + ]) + ->assertOk() + ->assertJson(['saved' => true]); + + $this->assertEquals('new-api-key', $this->addon->settings()->get('api_key')); + } + + #[Test] + public function can_update_addon_settings_with_edit_addon_settings_permission() + { + $this->addon->settings()->set(['api_key' => 'original-api-key'])->save(); + + $this->setTestRoles(['test' => ['access cp', 'edit vendor/test-addon settings']]); + + $this + ->actingAs(User::make()->assignRole('test')->save()) + ->patch(cp_route('addons.settings.edit', 'test-addon'), [ + 'api_key' => 'new-api-key', + ]) + ->assertOk() + ->assertJson(['saved' => true]); + + $this->assertEquals('new-api-key', $this->addon->settings()->get('api_key')); + } + + #[Test] + public function cant_update_addon_settings_without_permission() + { + $this->addon->settings()->set(['api_key' => 'original-api-key'])->save(); + + $this->setTestRoles(['test' => ['access cp']]); + + $this + ->actingAs(User::make()->assignRole('test')->save()) + ->patch(cp_route('addons.settings.edit', 'test-addon'), [ + 'api_key' => 'new-api-key', + ]) + ->assertRedirect('/cp'); + + $this->assertEquals('original-api-key', $this->addon->settings()->get('api_key')); + } + + #[Test] + public function cant_update_addon_settings_for_non_existent_addon() + { + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patch(cp_route('addons.settings.edit', 'non-existent-addon'), [ + 'api_key' => 'new-api-key', + ]) + ->assertNotFound(); + } + + #[Test] + public function cant_update_addon_settings_when_addon_doesnt_have_any_settings() + { + $this->addon->settings()->set(['api_key' => 'original-api-key'])->save(); + + // Forget the settings blueprint from the container. + $this->app->offsetUnset('statamic.addons.test-addon.settings_blueprint'); + + $this + ->actingAs(User::make()->makeSuper()->save()) + ->patch(cp_route('addons.settings.edit', 'test-addon'), [ + 'api_key' => 'new-api-key', + ]) + ->assertNotFound(); + + $this->assertEquals('original-api-key', $this->addon->settings()->get('api_key')); + } + + 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/Feature/Addons/ViewAddonListingTest.php b/tests/Feature/Addons/ViewAddonListingTest.php new file mode 100644 index 00000000000..a406bf49ec1 --- /dev/null +++ b/tests/Feature/Addons/ViewAddonListingTest.php @@ -0,0 +1,131 @@ +andReturn(collect([ + 'statamic/seo-pro' => $seoPro = $this->makeFromPackage([ + 'id' => 'statamic/seo-pro', + 'name' => 'SEO Pro', + 'slug' => 'seo-pro', + 'description' => 'An SEO addon for Statamic.', + 'developer' => 'Statamic', + 'version' => '6.7.0', + 'isCommercial' => true, + 'marketplaceId' => 10, + 'marketplaceSlug' => 'seo-pro', + 'marketplaceUrl' => 'https://statamic.com/addons/statamic/seo-pro', + ]), + 'statamic/importer' => $importer = $this->makeFromPackage([ + 'id' => 'statamic/importer', + 'name' => 'Importer', + 'slug' => 'importer', + 'description' => 'An importer addon for Statamic.', + 'developer' => 'Statamic', + 'version' => '1.8.4', + 'isCommercial' => false, + 'marketplaceId' => 20, + 'marketplaceSlug' => 'importer', + 'marketplaceUrl' => 'https://statamic.com/addons/statamic/importer', + ]), + 'vendor/test-addon' => $testAddon = $this->makeFromPackage([ + 'id' => 'vendor/test-addon', + 'name' => 'Test Addon', + 'slug' => 'test-addon', + 'description' => null, + 'developer' => 'Test Developer LLC', + 'version' => 'dev-main', + 'isCommercial' => false, + 'marketplaceId' => null, + 'marketplaceSlug' => null, + 'marketplaceUrl' => null, + ]), + ])); + + Facades\Addon::shouldReceive('get')->with('statamic/seo-pro')->andReturn($seoPro); + Facades\Addon::shouldReceive('get')->with('statamic/importer')->andReturn($importer); + Facades\Addon::shouldReceive('get')->with('vendor/test-addon')->andReturn($testAddon); + + // It doesn't need to be a real blueprint, it just needs to be bound. + $this->app->bind('statamic.addons.seo-pro.settings_blueprint', fn () => []); + + $this + ->actingAs(User::make()->makeSuper()->save()) + ->get(cp_route('addons.index')) + ->assertOk() + ->assertViewHas('addons', [ + [ + 'name' => 'SEO Pro', + 'version' => '6.7.0', + 'developer' => 'Statamic', + 'description' => 'An SEO addon for Statamic.', + 'marketplace_url' => 'https://statamic.com/addons/statamic/seo-pro', + 'updates_url' => cp_route('updater.product', 'seo-pro'), + 'settings_url' => cp_route('addons.settings.edit', 'seo-pro'), + ], + [ + 'name' => 'Importer', + 'version' => '1.8.4', + 'developer' => 'Statamic', + 'description' => 'An importer addon for Statamic.', + 'marketplace_url' => 'https://statamic.com/addons/statamic/importer', + 'updates_url' => cp_route('updater.product', 'importer'), + 'settings_url' => null, + ], + [ + 'name' => 'Test Addon', + 'version' => 'dev-main', + 'developer' => 'Test Developer LLC', + 'description' => null, + 'marketplace_url' => null, + 'updates_url' => null, + 'settings_url' => null, + ], + ]); + } + + #[Test] + public function it_doesnt_show_a_list_of_addons_without_configure_addons_permission() + { + $this->setTestRoles(['test' => ['access cp']]); + + $this + ->actingAs(User::make()->assignRole('test')->save()) + ->get(cp_route('addons.index')) + ->assertRedirect('/cp'); + } + + 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/ModifiesAddonManifest.php b/tests/ModifiesAddonManifest.php index 457976fd724..c20f0e063e0 100644 --- a/tests/ModifiesAddonManifest.php +++ b/tests/ModifiesAddonManifest.php @@ -2,7 +2,7 @@ namespace Tests; -use Statamic\Extend\Manifest; +use Statamic\Addons\Manifest; trait ModifiesAddonManifest {