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 @@
-
-
-
-
-
-
-
-
-
-
- {{ __('Installed') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ addon.name }}
-
-
-
-
-
-
-
-
-
-
-
- {{ __('Unlisted Addons') }}
-
-
-
-
-
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 @@
+
+
+
+
+
+ {{ __(addon.name) }}
+
+ {{ __(addon.name) }}
+
+
+
+
+ {{ handle }}
+
+
+
+
+
+
+
+
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
{