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

Issue:
Problems with `user_roles` & `user_groups` fieldtypes
> Issue created from conversation in: https://github.com/statamic-rad-pack/runway/issues/196#issuecomment-2095457819

I am currently experiencing problems when trying to set up users as a Runway resource.
While relationships and computed field function perfectly, it has caused an issue with the permission fields:

The user roles & user groups fields no longer function correctly, it seems Runway does not support the `user_roles` & `user_groups` field types?
I don´t see it listed in the list of supported fieldtypes: https://runway.duncanmcclean.com/blueprints#content-supported-fieldtypes

However, in the linked conversation you did mention that these should work as long as the columns are configured properly, and while they do not immediately throw an error, they do not function as they normally would.
Statamic manages permissions through the `role_user` & `group_user` tables, but the field created from the Runway blueprint attempts to save the values into columns directly (which causes an error for a missing column).
I can´t find a way to configure Statamic to manage roles & groups through columns instead, so I am unsure how to properly implement these field types, especially since there is currently no documentation on how to do so.

How should roles & groups be properly implemented for a user runway resource?

Pull request description:
[7.x] Handle roles and users fields for user models
This pull request adds some special handling around the Role & Group fields when the `User` model is configured as a Runway resource. 

Closes #485.

Patch:
diff --git a/src/Http/Controllers/CP/Traits/PreparesModels.php b/src/Http/Controllers/CP/Traits/PreparesModels.php
index 07d65a9..9fc7c80 100644
--- a/src/Http/Controllers/CP/Traits/PreparesModels.php
+++ b/src/Http/Controllers/CP/Traits/PreparesModels.php
@@ -6,6 +6,7 @@ use Carbon\CarbonInterface;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Http\Request;
 use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
 use Statamic\Fields\Field;
 use Statamic\Fieldtypes\Section;
@@ -28,10 +29,10 @@ trait PreparesModels
         );
 
         return $blueprint->fields()->all()
-            ->mapWithKeys(function (Field $field) use ($model) {
+            ->mapWithKeys(function (Field $field) use ($resource, $model) {
                 $value = data_get($model, Str::replace('->', '.', $field->handle()));
 
-                // When $value is a Carbon instance, format it it with the format defined in the blueprint.
+                // When $value is a Carbon instance, format it with the format defined in the blueprint.
                 if ($value instanceof CarbonInterface) {
                     $format = $field->get('format', 'Y-m-d H:i');
 
@@ -43,6 +44,23 @@ trait PreparesModels
                     $value = json_decode((string) $value, true);
                 }
 
+                // When the resource is a User model, handle the special role & group fields.
+                if ($this->isUserResource($resource)) {
+                    if ($field->handle() === 'roles') {
+                        $value = DB::table(config('statamic.users.tables.role_user'))
+                            ->where('user_id', $model->getKey())
+                            ->pluck('role_id')
+                            ->all();
+                    }
+
+                    if ($field->handle() === 'groups') {
+                        $value = DB::table(config('statamic.users.tables.group_user'))
+                            ->where('user_id', $model->getKey())
+                            ->pluck('group_id')
+                            ->all();
+                    }
+                }
+
                 // HasMany fieldtype: when reordering is enabled, we need to ensure the models are returned in the correct order.
                 if ($field->fieldtype() instanceof HasManyFieldtype && $field->get('reorderable', false)) {
                     $orderColumn = $field->get('order_column');
@@ -63,7 +81,7 @@ trait PreparesModels
 
         $blueprint->fields()->setParent($model)->all()
             ->filter(fn (Field $field) => $this->shouldSaveField($field))
-            ->each(function (Field $field) use (&$model, $request) {
+            ->each(function (Field $field) use ($resource, &$model, $request) {
                 $processedValue = $field->fieldtype()->process($request->get($field->handle()));
 
                 if ($field->fieldtype() instanceof HasManyFieldtype) {
@@ -80,6 +98,39 @@ trait PreparesModels
                     $processedValue = Arr::first($processedValue);
                 }
 
+                // When the resource is a User model, handle the special role & group fields.
+                if ($this->isUserResource($resource)) {
+                    if ($field->handle() === 'roles') {
+                        DB::table(config('statamic.users.tables.role_user'))
+                            ->where('user_id', $model->getKey())
+                            ->delete();
+
+                        DB::table(config('statamic.users.tables.role_user'))
+                            ->insert(
+                                collect($processedValue)
+                                    ->map(fn ($role) => ['user_id' => $model->getKey(), 'role_id' => $role])
+                                    ->all()
+                            );
+
+                        return;
+                    }
+
+                    if ($field->handle() === 'groups') {
+                        DB::table(config('statamic.users.tables.group_user'))
+                            ->where('user_id', $model->getKey())
+                            ->delete();
+
+                        DB::table(config('statamic.users.tables.group_user'))
+                            ->insert(
+                                collect($processedValue)
+                                    ->map(fn ($group) => ['user_id' => $model->getKey(), 'group_id' => $group])
+                                    ->all()
+                            );
+
+                        return;
+                    }
+                }
+
                 // When $processedValue is null and there's no cast set on the model, we should JSON encode it.
                 if (
                     is_array($processedValue)
@@ -109,4 +160,13 @@ trait PreparesModels
 
         return true;
     }
+
+    protected function isUserResource(Resource $resource): bool
+    {
+        $guard = config('statamic.users.guards.cp', 'web');
+        $guardProvider = config("auth.guards.{$guard}.provider", 'users');
+        $providerModel = config("auth.providers.{$guardProvider}.model", 'App\\Models\\User');
+
+        return $providerModel === get_class($resource->model());
+    }
 }


Test patch:
diff --git a/tests/Console/Commands/GenerateBlueprintTest.php b/tests/Console/Commands/GenerateBlueprintTest.php
index 346ba3a..98af0c3 100644
--- a/tests/Console/Commands/GenerateBlueprintTest.php
+++ b/tests/Console/Commands/GenerateBlueprintTest.php
@@ -25,7 +25,7 @@ class GenerateBlueprintTest extends TestCase
     /** @test */
     public function can_generate_blueprint()
     {
-        Schema::shouldReceive('dropIfExists')->times(3);
+        Schema::shouldReceive('dropIfExists')->times(7);
 
         Schema::shouldReceive('getColumns')
             ->with('posts')
@@ -128,7 +128,7 @@ class GenerateBlueprintTest extends TestCase
     /** @test */
     public function can_generate_resource_with_column_that_can_not_be_matched_to_a_fieldtype()
     {
-        Schema::shouldReceive('dropIfExists')->times(3);
+        Schema::shouldReceive('dropIfExists')->times(7);
 
         Schema::shouldReceive('getColumns')
             ->with('posts')
diff --git a/tests/Console/Commands/RebuildUriCacheTest.php b/tests/Console/Commands/RebuildUriCacheTest.php
index 6bbdc45..d3f56cb 100644
--- a/tests/Console/Commands/RebuildUriCacheTest.php
+++ b/tests/Console/Commands/RebuildUriCacheTest.php
@@ -67,7 +67,7 @@ class RebuildUriCacheTest extends TestCase
                 'You are about to rebuild your entire URI cache. This may take part of your site down while running. Are you sure you want to continue?',
                 'yes'
             )
-            ->expectsOutputToContain('Skipping Author, routing not configured.');
+            ->expectsOutputToContain('Skipping Authors, routing not configured.');
 
         $this->assertCount(0, RunwayUri::all());
     }
diff --git a/tests/GraphQL/ResourceInterfaceTest.php b/tests/GraphQL/ResourceInterfaceTest.php
index 1672717..ce2a187 100644
--- a/tests/GraphQL/ResourceInterfaceTest.php
+++ b/tests/GraphQL/ResourceInterfaceTest.php
@@ -13,6 +13,7 @@ class ResourceInterfaceTest extends TestCase
         $this->assertEquals([
             'runway_graphql_types_post' => 'runway_graphql_types_post',
             'runway_graphql_types_author' => 'runway_graphql_types_author',
+            'runway_graphql_types_user' => 'runway_graphql_types_user',
         ], GraphQL::getTypes());
     }
 }
diff --git a/tests/Http/Controllers/CP/ResourceControllerTest.php b/tests/Http/Controllers/CP/ResourceControllerTest.php
index a845093..5b41973 100644
--- a/tests/Http/Controllers/CP/ResourceControllerTest.php
+++ b/tests/Http/Controllers/CP/ResourceControllerTest.php
@@ -2,12 +2,16 @@
 
 namespace StatamicRadPack\Runway\Tests\Http\Controllers\CP;
 
+use Illuminate\Support\Facades\DB;
 use Statamic\Facades\Blueprint;
 use Statamic\Facades\Config;
+use Statamic\Facades\Role;
 use Statamic\Facades\User;
+use Statamic\Facades\UserGroup;
 use StatamicRadPack\Runway\Runway;
 use StatamicRadPack\Runway\Tests\Fixtures\Models\Author;
 use StatamicRadPack\Runway\Tests\Fixtures\Models\Post;
+use StatamicRadPack\Runway\Tests\Fixtures\Models\User as UserModel;
 use StatamicRadPack\Runway\Tests\TestCase;
 
 class ResourceControllerTest extends TestCase
@@ -458,6 +462,32 @@ class ResourceControllerTest extends TestCase
             ->assertSee($post->external_links->links[1]->url);
     }
 
+    /** @test */
+    public function can_edit_resource_if_model_is_user_model()
+    {
+        Config::set('auth.providers.users.model', UserModel::class);
+
+        $user = UserModel::create([
+            'name' => 'John Doe',
+            'email' => 'john.doe@example.com',
+        ]);
+
+        UserGroup::make('admins')->title('Admins')->save();
+        DB::table('group_user')->insert(['user_id' => $user->id, 'group_id' => 'admins']);
+
+        Role::make('developer')->title('Developer')->save();
+        DB::table('role_user')->insert(['user_id' => $user->id, 'role_id' => 'developer']);
+
+        $this
+            ->actingAs(User::make()->makeSuper()->save())
+            ->get(cp_route('runway.edit', ['resource' => 'user', 'model' => $user->id]))
+            ->assertOk()
+            ->assertSee($user->name)
+            ->assertSee($user->email)
+            ->assertSee('developer')
+            ->assertSee('admins');
+    }
+
     /** @test */
     public function can_update_resource()
     {
@@ -643,4 +673,45 @@ class ResourceControllerTest extends TestCase
 
         $this->assertEquals($post->values['alt_title'], 'Claus is venturing out');
     }
+
+    /** @test */
+    public function can_update_resource_if_model_is_user_model()
+    {
+        Config::set('auth.providers.users.model', UserModel::class);
+
+        $user = UserModel::create([
+            'name' => 'John Doe',
+            'email' => 'john.doe@example.com',
+        ]);
+
+        UserGroup::make('admins')->title('Admins')->save();
+        Role::make('developer')->title('Developer')->save();
+
+        $this
+            ->actingAs(User::make()->makeSuper()->save())
+            ->patch(cp_route('runway.update', ['resource' => 'user', 'model' => $user->id]), [
+                'name' => 'Jane Doe',
+                'email' => 'jane.doe@example.com',
+                'roles' => ['developer'],
+                'groups' => ['admins'],
+            ])
+            ->assertOk()
+            ->assertJsonStructure([
+                'data',
+            ]);
+
+        $user->refresh();
+
+        $this->assertEquals($user->name, 'Jane Doe');
+
+        $this->assertDatabaseHas('role_user', [
+            'user_id' => $user->id,
+            'role_id' => 'developer',
+        ]);
+
+        $this->assertDatabaseHas('group_user', [
+            'user_id' => $user->id,
+            'group_id' => 'admins',
+        ]);
+    }
 }
diff --git a/tests/RunwayTest.php b/tests/RunwayTest.php
index 923e105..f3b644a 100644
--- a/tests/RunwayTest.php
+++ b/tests/RunwayTest.php
@@ -15,20 +15,25 @@ class RunwayTest extends TestCase
     {
         Runway::discoverResources();
 
-        $all = Runway::allResources();
+        $all = Runway::allResources()->values();
 
         $this->assertTrue($all instanceof Collection);
-        $this->assertCount(2, $all);
-
-        $this->assertTrue($all->first() instanceof Resource);
-        $this->assertEquals('post', $all->first()->handle());
-        $this->assertTrue($all->first()->model() instanceof Model);
-        $this->assertTrue($all->first()->blueprint() instanceof Blueprint);
-
-        $this->assertTrue($all->last() instanceof Resource);
-        $this->assertEquals('author', $all->last()->handle());
-        $this->assertTrue($all->last()->model() instanceof Model);
-        $this->assertTrue($all->last()->blueprint() instanceof Blueprint);
+        $this->assertCount(3, $all);
+
+        $this->assertTrue($all[0] instanceof Resource);
+        $this->assertEquals('post', $all[0]->handle());
+        $this->assertTrue($all[0]->model() instanceof Model);
+        $this->assertTrue($all[0]->blueprint() instanceof Blueprint);
+
+        $this->assertTrue($all[1] instanceof Resource);
+        $this->assertEquals('author', $all[1]->handle());
+        $this->assertTrue($all[1]->model() instanceof Model);
+        $this->assertTrue($all[1]->blueprint() instanceof Blueprint);
+
+        $this->assertTrue($all[2] instanceof Resource);
+        $this->assertEquals('user', $all[2]->handle());
+        $this->assertTrue($all[2]->model() instanceof Model);
+        $this->assertTrue($all[2]->blueprint() instanceof Blueprint);
     }
 
     /** @test */
diff --git a/tests/__fixtures__/app/Models/User.php b/tests/__fixtures__/app/Models/User.php
new file mode 100644
index 0000000..d0d62aa
--- /dev/null
+++ b/tests/__fixtures__/app/Models/User.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace StatamicRadPack\Runway\Tests\Fixtures\Models;
+
+use Illuminate\Foundation\Auth\User as Authenticatable;
+use StatamicRadPack\Runway\Traits\HasRunwayResource;
+
+class User extends Authenticatable
+{
+    use HasRunwayResource;
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array<int, string>
+     */
+    protected $fillable = [
+        'name',
+        'email',
+        'password',
+    ];
+
+    /**
+     * The attributes that should be hidden for serialization.
+     *
+     * @var array<int, string>
+     */
+    protected $hidden = [
+        'password',
+        'remember_token',
+    ];
+
+    /**
+     * The attributes that should be cast.
+     *
+     * @var array<string, string>
+     */
+    protected $casts = [
+        'email_verified_at' => 'datetime',
+        'preferences' => 'json',
+    ];
+}
diff --git a/tests/__fixtures__/config/runway.php b/tests/__fixtures__/config/runway.php
index a501027..b3b3e18 100644
--- a/tests/__fixtures__/config/runway.php
+++ b/tests/__fixtures__/config/runway.php
@@ -2,6 +2,7 @@
 
 use StatamicRadPack\Runway\Tests\Fixtures\Models\Author;
 use StatamicRadPack\Runway\Tests\Fixtures\Models\Post;
+use StatamicRadPack\Runway\Tests\Fixtures\Models\User;
 
 return [
     'resources' => [
@@ -20,7 +21,7 @@ return [
         ],
 
         Author::class => [
-            'name' => 'Author',
+            'name' => 'Authors',
             'listing' => [
                 'columns' => [
                     'name',
@@ -31,5 +32,9 @@ return [
                 ],
             ],
         ],
+
+        User::class => [
+            'name' => 'Users',
+        ],
     ],
 ];
diff --git a/tests/__fixtures__/database/migrations/2014_10_12_000000_create_users_tables.php b/tests/__fixtures__/database/migrations/2014_10_12_000000_create_users_tables.php
new file mode 100644
index 0000000..623a7cc
--- /dev/null
+++ b/tests/__fixtures__/database/migrations/2014_10_12_000000_create_users_tables.php
@@ -0,0 +1,59 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('users', function (Blueprint $table) {
+            $table->id();
+            $table->string('name');
+            $table->string('email')->unique();
+            $table->timestamp('email_verified_at')->nullable();
+            $table->string('password')->nullable();
+            $table->rememberToken();
+            $table->timestamps();
+            $table->boolean('super')->default(false);
+            $table->string('avatar')->nullable();
+            $table->json('preferences')->nullable();
+            $table->timestamp('last_login')->nullable();
+        });
+
+        Schema::create('role_user', function (Blueprint $table) {
+            $table->id('id');
+            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+            $table->string('role_id');
+        });
+
+        Schema::create('group_user', function (Blueprint $table) {
+            $table->id('id');
+            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+            $table->string('group_id');
+        });
+
+        Schema::create('password_activation_tokens', function (Blueprint $table) {
+            $table->string('email')->index();
+            $table->string('token');
+            $table->timestamp('created_at')->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('users');
+
+        Schema::dropIfExists('role_user');
+        Schema::dropIfExists('group_user');
+
+        Schema::dropIfExists('password_activation_tokens');
+    }
+};
diff --git a/tests/__fixtures__/resources/blueprints/vendor/runway/user.yaml b/tests/__fixtures__/resources/blueprints/vendor/runway/user.yaml
new file mode 100644
index 0000000..ad6bac3
--- /dev/null
+++ b/tests/__fixtures__/resources/blueprints/vendor/runway/user.yaml
@@ -0,0 +1,23 @@
+title: User
+fields:
+  -
+    handle: name
+    field:
+      type: text
+      display: Name
+  -
+    handle: email
+    field:
+      type: text
+      input: email
+      display: Email Address
+  -
+    handle: roles
+    field:
+      type: user_roles
+      width: 50
+  -
+    handle: groups
+    field:
+      type: user_groups
+      width: 50