Repository: statamic-rad-pack/runway (php)

Issue:
Resource-Level Actions Not Working
### Description

### Disclaimer
I'm not familiar with resource-level Actions and don't know how they're _supposed_ to work, so I'm not much help here and I also could be missing something obvious. Just couldn't find any info in Runway or Statamic docs, on this particular type of action. Very possible I'm just wiring this up wrong. Could also be an issue with Statamic, more so than Runway.

### Related PRs
- [Statamic #10471](https://github.com/statamic/cms/pull/10471/)
- [Runway #564](https://github.com/statamic-rad-pack/runway/pull/564/)

### Issue
When running an Action at the resource-level, the Action throws an exception that _sounds_ like it's still expecting selected/specific models to be passed through, which are not available (to my understanding) with resource-level Actions.

### Steps to reproduce

1. Setup a simple model/resource
2. Setup a _really_ simple Action—can just log something in the `run` method
3. Don't restrict Action's visibility, so it's available to the resource itself
4. Attempt to run it from the resource, see error below

![image](https://github.com/user-attachments/assets/c95485d8-f55a-489b-8997-adae8a9f1cc3)


### Environment

<details>
  <summary>Environment</summary>

Environment
Application Name: Statamic
Laravel Version: 11.34.2
PHP Version: 8.2.5
Composer Version: 2.5.4
Environment: local
Debug Mode: ENABLED
URL: runway-sandbox.test
Maintenance Mode: OFF
Timezone: UTC
Locale: en

Cache
Config: NOT CACHED
Events: NOT CACHED
Routes: NOT CACHED
Views: CACHED

Drivers
Broadcasting: log
Cache: file
Database: mysql
Logs: stack / single
Mail: log
Queue: sync
Session: file

Statamic
Addons: 1
Sites: 1
Stache Watcher: Enabled (auto)
Static Caching: Disabled
Version: 5.41.0 Solo

Statamic Addons
statamic-rad-pack/runway: 7.x-dev
</details>

Pull request:
[7.x] Fix error when running resource actions
This pull request fixes an error when running resource actions, where code was expecting a `Model` instance to be passed in when it was a `Resource`.

It looks like "resource actions" and "model actions" were sharing the same controller. This PR splits them out to match how Statamic handles collection/entry actions.

I have no idea how resource actions were working in the first place. 🤷‍♂️

Fixes #637.

Patch:
diff --git a/resources/views/edit.blade.php b/resources/views/edit.blade.php
index 0413268..89a7a25 100644
--- a/resources/views/edit.blade.php
+++ b/resources/views/edit.blade.php
@@ -24,7 +24,7 @@
         create-another-url="{{ cp_route('runway.create', ['resource' => $resource->handle()]) }}"
         initial-listing-url="{{ cp_route('runway.index', ['resource' => $resource->handle()]) }}"
         :initial-item-actions="{{ json_encode($itemActions) }}"
-        item-action-url="{{ cp_route('runway.actions.run', ['resource' => $resource->handle()]) }}"
+        item-action-url="{{ cp_route('runway.models.actions.run', ['resource' => $resource->handle()]) }}"
         :revisions-enabled="{{ $str::bool($revisionsEnabled) }}"
     ></runway-publish-form>
 
diff --git a/routes/cp.php b/routes/cp.php
index f5ad545..68c9224 100644
--- a/routes/cp.php
+++ b/routes/cp.php
@@ -1,6 +1,7 @@
 <?php
 
 use Illuminate\Support\Facades\Route;
+use StatamicRadPack\Runway\Http\Controllers\CP\ModelActionController;
 use StatamicRadPack\Runway\Http\Controllers\CP\ModelRevisionsController;
 use StatamicRadPack\Runway\Http\Controllers\CP\PublishedModelsController;
 use StatamicRadPack\Runway\Http\Controllers\CP\ResourceActionController;
@@ -10,10 +11,12 @@ use StatamicRadPack\Runway\Http\Controllers\CP\RestoreModelRevisionController;
 
 Route::name('runway.')->prefix('runway')->group(function () {
     Route::get('/{resource}', [ResourceController::class, 'index'])->name('index');
-
     Route::get('{resource}/listing-api', [ResourceListingController::class, 'index'])->name('listing-api');
-    Route::post('{resource}/actions', [ResourceActionController::class, 'runAction'])->name('actions.run');
-    Route::post('{resource}/actions/list', [ResourceActionController::class, 'bulkActionsList'])->name('actions.bulk');
+
+    Route::post('{resource}/actions', [ResourceActionController::class, 'run'])->name('actions.run');
+
+    Route::post('{resource}/models/actions', [ModelActionController::class, 'runAction'])->name('models.actions.run');
+    Route::post('{resource}/models/actions/list', [ModelActionController::class, 'bulkActionsList'])->name('models.actions.bulk');
 
     Route::get('{resource}/create', [ResourceController::class, 'create'])->name('create');
     Route::post('{resource}/create', [ResourceController::class, 'store'])->name('store');
diff --git a/src/Fieldtypes/HasManyFieldtype.php b/src/Fieldtypes/HasManyFieldtype.php
index 628b963..f6b0487 100644
--- a/src/Fieldtypes/HasManyFieldtype.php
+++ b/src/Fieldtypes/HasManyFieldtype.php
@@ -61,7 +61,7 @@ class HasManyFieldtype extends BaseFieldtype
     public function preload()
     {
         return array_merge(parent::preload(), [
-            'actionUrl' => cp_route('runway.actions.run', [
+            'actionUrl' => cp_route('runway.models.actions.run', [
                 'resource' => $this->config('resource'),
             ]),
         ]);
diff --git a/src/Http/Controllers/CP/ModelActionController.php b/src/Http/Controllers/CP/ModelActionController.php
new file mode 100644
index 0000000..973f80c
--- /dev/null
+++ b/src/Http/Controllers/CP/ModelActionController.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace StatamicRadPack\Runway\Http\Controllers\CP;
+
+use Illuminate\Http\Request;
+use Statamic\Facades\Action;
+use Statamic\Http\Controllers\CP\ActionController;
+use StatamicRadPack\Runway\Resource;
+
+class ModelActionController extends ActionController
+{
+    use Traits\ExtractsFromModelFields;
+
+    protected $resource;
+
+    public function runAction(Request $request, Resource $resource)
+    {
+        $this->resource = $resource;
+
+        return parent::run($request);
+    }
+
+    public function bulkActionsList(Request $request, Resource $resource)
+    {
+        $this->resource = $resource;
+
+        return parent::bulkActions($request);
+    }
+
+    protected function getSelectedItems($items, $context)
+    {
+        return $this->resource->findMany($items);
+    }
+
+    protected function getItemData($model, $context): array
+    {
+        $blueprint = $this->resource->blueprint();
+
+        [$values] = $this->extractFromFields($model, $this->resource, $blueprint);
+
+        return [
+            'title' => $model->getAttribute($this->resource->titleField()),
+            'values' => array_merge($values, ['id' => $model->getKey()]),
+            'itemActions' => Action::for($model, $context),
+        ];
+    }
+}
diff --git a/src/Http/Controllers/CP/ResourceActionController.php b/src/Http/Controllers/CP/ResourceActionController.php
index 65fdd23..2971561 100644
--- a/src/Http/Controllers/CP/ResourceActionController.php
+++ b/src/Http/Controllers/CP/ResourceActionController.php
@@ -2,46 +2,13 @@
 
 namespace StatamicRadPack\Runway\Http\Controllers\CP;
 
-use Illuminate\Http\Request;
-use Statamic\Facades\Action;
 use Statamic\Http\Controllers\CP\ActionController;
-use StatamicRadPack\Runway\Resource;
+use StatamicRadPack\Runway\Runway;
 
 class ResourceActionController extends ActionController
 {
-    use Traits\ExtractsFromModelFields;
-
-    protected $resource;
-
-    public function runAction(Request $request, Resource $resource)
-    {
-        $this->resource = $resource;
-
-        return parent::run($request);
-    }
-
-    public function bulkActionsList(Request $request, Resource $resource)
-    {
-        $this->resource = $resource;
-
-        return parent::bulkActions($request);
-    }
-
     protected function getSelectedItems($items, $context)
     {
-        return $this->resource->findMany($items);
-    }
-
-    protected function getItemData($model, $context): array
-    {
-        $blueprint = $this->resource->blueprint();
-
-        [$values] = $this->extractFromFields($model, $this->resource, $blueprint);
-
-        return [
-            'title' => $model->getAttribute($this->resource->titleField()),
-            'values' => array_merge($values, ['id' => $model->getKey()]),
-            'itemActions' => Action::for($model, $context),
-        ];
+        return $items->map(fn ($item) => Runway::findResource($item));
     }
 }
diff --git a/src/Http/Controllers/CP/ResourceController.php b/src/Http/Controllers/CP/ResourceController.php
index a7326a4..79119b2 100644
--- a/src/Http/Controllers/CP/ResourceController.php
+++ b/src/Http/Controllers/CP/ResourceController.php
@@ -44,7 +44,7 @@ class ResourceController extends CpController
                 ->rejectUnlisted()
                 ->values(),
             'filters' => Scope::filters('runway', ['resource' => $resource->handle()]),
-            'actionUrl' => cp_route('runway.actions.run', ['resource' => $resource->handle()]),
+            'actionUrl' => cp_route('runway.models.actions.run', ['resource' => $resource->handle()]),
             'primaryColumn' => $this->getPrimaryColumn($resource),
             'actions' => Action::for($resource, ['view' => 'form']),
         ]);


Test patch:
diff --git a/tests/Console/Commands/GenerateMigrationTest.php b/tests/Console/Commands/GenerateMigrationTest.php
index 8639628..17a6947 100644
--- a/tests/Console/Commands/GenerateMigrationTest.php
+++ b/tests/Console/Commands/GenerateMigrationTest.php
@@ -19,7 +19,7 @@ class GenerateMigrationTest extends TestCase
 {
     use RefreshDatabase;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
@@ -37,7 +37,7 @@ class GenerateMigrationTest extends TestCase
         });
     }
 
-    public function tearDown(): void
+    protected function tearDown(): void
     {
         parent::tearDown();
 
diff --git a/tests/Fieldtypes/BelongsToFieldtypeTest.php b/tests/Fieldtypes/BelongsToFieldtypeTest.php
index 52926fd..dbb25cf 100644
--- a/tests/Fieldtypes/BelongsToFieldtypeTest.php
+++ b/tests/Fieldtypes/BelongsToFieldtypeTest.php
@@ -21,7 +21,7 @@ class BelongsToFieldtypeTest extends TestCase
 
     protected BelongsToFieldtype $fieldtype;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
diff --git a/tests/Fieldtypes/HasManyFieldtypeTest.php b/tests/Fieldtypes/HasManyFieldtypeTest.php
index 9aa9cad..4d3d50b 100644
--- a/tests/Fieldtypes/HasManyFieldtypeTest.php
+++ b/tests/Fieldtypes/HasManyFieldtypeTest.php
@@ -25,7 +25,7 @@ class HasManyFieldtypeTest extends TestCase
 
     protected HasManyFieldtype $fieldtype;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
diff --git a/tests/Http/Controllers/ApiControllerTest.php b/tests/Http/Controllers/ApiControllerTest.php
index 770d11c..e214688 100644
--- a/tests/Http/Controllers/ApiControllerTest.php
+++ b/tests/Http/Controllers/ApiControllerTest.php
@@ -9,7 +9,7 @@ use StatamicRadPack\Runway\Tests\TestCase;
 
 class ApiControllerTest extends TestCase
 {
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
diff --git a/tests/Http/Controllers/CP/ModelActionControllerTest.php b/tests/Http/Controllers/CP/ModelActionControllerTest.php
new file mode 100644
index 0000000..20fa055
--- /dev/null
+++ b/tests/Http/Controllers/CP/ModelActionControllerTest.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace StatamicRadPack\Runway\Tests\Http\Controllers\CP;
+
+use PHPUnit\Framework\Attributes\Test;
+use Statamic\Actions\Action;
+use Statamic\Facades\User;
+use StatamicRadPack\Runway\Tests\Fixtures\Models\Post;
+use StatamicRadPack\Runway\Tests\TestCase;
+
+class ModelActionControllerTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        FooAction::register();
+    }
+
+    #[Test]
+    public function can_run_action()
+    {
+        $post = Post::factory()->create();
+
+        $this->assertFalse(FooAction::$hasRun);
+
+        $this
+            ->actingAs(User::make()->makeSuper()->save())
+            ->post('/cp/runway/post/models/actions', [
+                'action' => 'foo',
+                'selections' => [$post->id],
+                'values' => [],
+            ])
+            ->assertOk()
+            ->assertJson(['message' => 'Foo action run!']);
+
+        $this->assertTrue(FooAction::$hasRun);
+    }
+
+    #[Test]
+    public function can_get_bulk_actions_list()
+    {
+        $post = Post::factory()->create();
+
+        $this
+            ->actingAs(User::make()->makeSuper()->save())
+            ->post('/cp/runway/post/models/actions/list', [
+                'selections' => [$post->id],
+            ])
+            ->assertOk()
+            ->assertJsonPath('0.handle', 'unpublish');
+    }
+}
+
+class FooAction extends Action
+{
+    protected static $handle = 'foo';
+
+    public static bool $hasRun = false;
+
+    public function run($items, $values)
+    {
+        static::$hasRun = true;
+
+        return 'Foo action run!';
+    }
+}
diff --git a/tests/Http/Controllers/CP/ModelRevisionsControllerTest.php b/tests/Http/Controllers/CP/ModelRevisionsControllerTest.php
index 4e4feb3..5f8f1a5 100644
--- a/tests/Http/Controllers/CP/ModelRevisionsControllerTest.php
+++ b/tests/Http/Controllers/CP/ModelRevisionsControllerTest.php
@@ -13,7 +13,7 @@ class ModelRevisionsControllerTest extends TestCase
 {
     private $dir;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
@@ -22,7 +22,7 @@ class ModelRevisionsControllerTest extends TestCase
         config(['statamic.revisions.path' => $this->dir]);
     }
 
-    public function tearDown(): void
+    protected function tearDown(): void
     {
         Folder::delete($this->dir);
 
diff --git a/tests/Http/Controllers/CP/PublishedModelsControllerTest.php b/tests/Http/Controllers/CP/PublishedModelsControllerTest.php
index ad6ffde..953a3e4 100644
--- a/tests/Http/Controllers/CP/PublishedModelsControllerTest.php
+++ b/tests/Http/Controllers/CP/PublishedModelsControllerTest.php
@@ -12,7 +12,7 @@ class PublishedModelsControllerTest extends TestCase
 {
     private $dir;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
@@ -21,7 +21,7 @@ class PublishedModelsControllerTest extends TestCase
         config(['statamic.revisions.path' => $this->dir]);
     }
 
-    public function tearDown(): void
+    protected function tearDown(): void
     {
         Folder::delete($this->dir);
 
diff --git a/tests/Http/Controllers/CP/ResourceActionControllerTest.php b/tests/Http/Controllers/CP/ResourceActionControllerTest.php
index efc9cd7..0bbbaa8 100644
--- a/tests/Http/Controllers/CP/ResourceActionControllerTest.php
+++ b/tests/Http/Controllers/CP/ResourceActionControllerTest.php
@@ -5,56 +5,39 @@ namespace StatamicRadPack\Runway\Tests\Http\Controllers\CP;
 use PHPUnit\Framework\Attributes\Test;
 use Statamic\Actions\Action;
 use Statamic\Facades\User;
-use StatamicRadPack\Runway\Tests\Fixtures\Models\Post;
 use StatamicRadPack\Runway\Tests\TestCase;
 
 class ResourceActionControllerTest extends TestCase
 {
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
-        FooAction::register();
+        BarAction::register();
     }
 
     #[Test]
     public function can_run_action()
     {
-        $post = Post::factory()->create();
-
-        $this->assertFalse(FooAction::$hasRun);
+        $this->assertFalse(BarAction::$hasRun);
 
         $this
             ->actingAs(User::make()->makeSuper()->save())
             ->post('/cp/runway/post/actions', [
-                'action' => 'foo',
-                'selections' => [$post->id],
+                'action' => 'bar',
+                'selections' => ['post'],
                 'values' => [],
             ])
             ->assertOk()
-            ->assertJson(['message' => 'Foo action run!']);
-
-        $this->assertTrue(FooAction::$hasRun);
-    }
-
-    #[Test]
-    public function can_get_bulk_actions_list()
-    {
-        $post = Post::factory()->create();
+            ->assertJson(['message' => 'Bar action run!']);
 
-        $this
-            ->actingAs(User::make()->makeSuper()->save())
-            ->post('/cp/runway/post/actions/list', [
-                'selections' => [$post->id],
-            ])
-            ->assertOk()
-            ->assertJsonPath('0.handle', 'unpublish');
+        $this->assertTrue(BarAction::$hasRun);
     }
 }
 
-class FooAction extends Action
+class BarAction extends Action
 {
-    protected static $handle = 'foo';
+    protected static $handle = 'bar';
 
     public static bool $hasRun = false;
 
@@ -62,6 +45,6 @@ class FooAction extends Action
     {
         static::$hasRun = true;
 
-        return 'Foo action run!';
+        return 'Bar action run!';
     }
 }
diff --git a/tests/Http/Controllers/CP/RestoreModelRevisionController.php b/tests/Http/Controllers/CP/RestoreModelRevisionController.php
index f2b47d0..0558dfc 100644
--- a/tests/Http/Controllers/CP/RestoreModelRevisionController.php
+++ b/tests/Http/Controllers/CP/RestoreModelRevisionController.php
@@ -12,7 +12,7 @@ class RestoreModelRevisionController extends TestCase
 {
     private $dir;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
@@ -21,7 +21,7 @@ class RestoreModelRevisionController extends TestCase
         config(['statamic.revisions.path' => $this->dir]);
     }
 
-    public function tearDown(): void
+    protected function tearDown(): void
     {
         Folder::delete($this->dir);
 
diff --git a/tests/Tags/RunwayTagTest.php b/tests/Tags/RunwayTagTest.php
index f8fc5d7..a295b97 100644
--- a/tests/Tags/RunwayTagTest.php
+++ b/tests/Tags/RunwayTagTest.php
@@ -17,7 +17,7 @@ class RunwayTagTest extends TestCase
 {
     public $tag;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();