diff --git a/src/Http/Controllers/ColumnStateController.php b/src/Http/Controllers/ColumnStateController.php new file mode 100644 index 0000000..2ca6fc4 --- /dev/null +++ b/src/Http/Controllers/ColumnStateController.php @@ -0,0 +1,253 @@ +resolveColumn($resourceClass::table(new Table), $request); + + $record = $this->resolveOwnerRecord($resourceClass, (string) $request->route('id')); + + abort_unless($this->canUpdateResourceRecord($resourceClass, $record), 403); + + $name = $column->getName(); + $value = $this->validateState($column, $request); + + $this->persist($column, $record, $name, $value); + + if ($request->wantsJson() && ! $request->header('X-Inertia')) { + return response()->json([ + 'success' => true, + 'column' => $name, + 'state' => $record->getAttribute($name), + ]); + } + + return back(); + } + + /** + * PATCH {slug}/{id}/relations/{relationship}/{relationId}/column + * + * @param class-string $resourceClass + */ + public function updateRelationColumn(Request $request, string $resourceClass): mixed + { + $relationship = (string) $request->route('relationship'); + + $relationManagerClass = null; + + foreach ($resourceClass::getRelations() as $rmClass) { + if ($rmClass::getRelationship() === $relationship) { + $relationManagerClass = $rmClass; + break; + } + } + + if (! $relationManagerClass) { + if ($request->wantsJson()) { + return response()->json(['error' => 'Relation manager not found'], 404); + } + + return back()->withErrors(['error' => 'Relation manager not found']); + } + + // The owner record is loaded through the resource query, so tenant scoping applies + $ownerRecord = $this->resolveOwnerRecord($resourceClass, (string) $request->route('id')); + + /** @var RelationManager $relationManager */ + $relationManager = $relationManagerClass::make($ownerRecord); + + $column = $this->resolveColumn($relationManager->table(new Table), $request); + + // The related record must belong to the (scoped) owner record + $relatedRecord = $relationManager->getRelationshipQuery()->findOrFail($request->route('relationId')); + + abort_unless($this->canUpdateRelatedRecord($resourceClass, $relationManager, $ownerRecord, $relatedRecord), 403); + + $name = $column->getName(); + $value = $this->validateState($column, $request); + + $this->persist($column, $relatedRecord, $name, $value); + + if ($request->wantsJson() && ! $request->header('X-Inertia')) { + return response()->json([ + 'success' => true, + 'data' => $relatedRecord, + ]); + } + + return back(); + } + + /** + * Whether the installed laravilt/tables ships the EditableColumn contract (tables > 1.0.x). + */ + protected function supportsEditableColumns(): bool + { + return interface_exists(EditableColumn::class); + } + + /** + * Find the requested column in the table and make sure it may be written inline. + */ + protected function resolveColumn(Table $table, Request $request): object + { + $name = $request->input('column'); + + abort_if(! is_string($name) || $name === '', 422, 'The column field is required.'); + + foreach ($table->getColumns() as $column) { + if ($column->getName() !== $name) { + continue; + } + + // Plain display columns (TextColumn, ...) can never be written through this endpoint + abort_unless($this->isEditableColumn($column), 403, 'This column is not editable.'); + // Relationship paths ("author.name") are display-only: only the record's own attribute is updated + abort_if(str_contains($name, '.'), 403, 'Relationship columns cannot be edited inline.'); + abort_if(method_exists($column, 'isDisabled') && $column->isDisabled(), 403, 'This column is disabled.'); + + return $column; + } + + abort(404, 'Column not found.'); + } + + protected function isEditableColumn(object $column): bool + { + if ($this->supportsEditableColumns()) { + return $column instanceof EditableColumn; + } + + // Legacy tables: ToggleColumn is the only inline-editable column + return $column instanceof ToggleColumn; + } + + /** + * @param class-string $resourceClass + */ + protected function resolveOwnerRecord(string $resourceClass, string $key): Model + { + // getEloquentQuery() applies the resource's tenant scoping + $query = method_exists($resourceClass, 'getEloquentQuery') + ? $resourceClass::getEloquentQuery() + : $resourceClass::getModel()::query(); + + return $query->whereKey($key)->firstOrFail(); + } + + /** + * @param class-string $resourceClass + */ + protected function canUpdateResourceRecord(string $resourceClass, Model $record): bool + { + // Resource authorization honours $usePolicies (policy "update") or the panel's permission system + if (method_exists($resourceClass, 'canUpdate')) { + return (bool) $resourceClass::canUpdate($record); + } + + if (Gate::getPolicyFor($record)) { + return Gate::allows('update', $record); + } + + return auth()->check(); + } + + /** + * Editing a related record inline requires the relation manager to allow editing, the user to be + * allowed to update the owner record, and (when the related model has a policy) to update the related record. + * + * @param class-string $resourceClass + */ + protected function canUpdateRelatedRecord(string $resourceClass, RelationManager $relationManager, Model $ownerRecord, Model $relatedRecord): bool + { + if (method_exists($relationManager, 'canEdit') && ! $relationManager->canEdit()) { + return false; + } + + if (! $this->canUpdateResourceRecord($resourceClass, $ownerRecord)) { + return false; + } + + if (Gate::getPolicyFor($relatedRecord)) { + return Gate::allows('update', $relatedRecord); + } + + return true; + } + + /** + * Validate the incoming value with the column's rules and return it converted for storage. + */ + protected function validateState(object $column, Request $request): mixed + { + $name = $column->getName(); + $label = method_exists($column, 'getLabel') && $column->getLabel() ? $column->getLabel() : $name; + + if ($this->supportsEditableColumns() && $column instanceof EditableColumn) { + $rules = $column->getStateValidationRules(); + } else { + $rules = ['required', 'boolean']; + } + + // Errors are keyed by the column name, which is what the Vue columns read on failure + $validated = Validator::make( + [$name => $request->input('value')], + [$name => $rules], + [], + [$name => $label], + )->validate(); + + $value = $validated[$name] ?? null; + + if ($this->supportsEditableColumns() && $column instanceof EditableColumn) { + return $column->dehydrateState($value); + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Write only this column's attribute, running the column's state callbacks around the save. + */ + protected function persist(object $column, Model $record, string $name, mixed $value): void + { + if (method_exists($column, 'getBeforeStateUpdated') && ($before = $column->getBeforeStateUpdated())) { + $before($record, $name, $value); + } + + $record->setAttribute($name, $value); + $record->save(); + + if (method_exists($column, 'getAfterStateUpdated') && ($after = $column->getAfterStateUpdated())) { + $after($record, $name, $value); + } + } +} diff --git a/src/PanelServiceProvider.php b/src/PanelServiceProvider.php index b81109d..3907119 100644 --- a/src/PanelServiceProvider.php +++ b/src/PanelServiceProvider.php @@ -44,8 +44,6 @@ use Laravilt\Panel\Tenancy\MultiDatabaseManager; use Laravilt\Support\Frontend; use Laravilt\Tables\ApiResource; -use Laravilt\Tables\Columns\ToggleColumn; -use Laravilt\Tables\Table; class PanelServiceProvider extends ServiceProvider { @@ -721,57 +719,10 @@ protected function registerSimpleResourceRoutes(string $resourceClass, string $s */ protected function registerColumnUpdateRoute(string $resourceClass, string $slug, string $modelClass, Panel $panel): void { - Route::patch($slug.'/{id}/column', function () use ($resourceClass, $modelClass) { - // Use named route parameter to handle subdomain routes where {tenant} is also a parameter - $id = request()->route('id'); - $record = $modelClass::findOrFail($id); - $column = request()->input('column'); - $value = request()->input('value'); - - // Validate input - if (empty($column)) { - return back()->withErrors(['column' => 'Column name is required.']); - } - - // Get the table configuration to find the column and its callbacks - $table = new Table; - $table = $resourceClass::table($table); - $columns = $table->getColumns(); - - // Find the column configuration - $columnConfig = null; - foreach ($columns as $col) { - if ($col->getName() === $column) { - $columnConfig = $col; - break; - } - } - - // Check if column exists and is editable - if (! $columnConfig) { - return back()->withErrors([$column => 'Column not found.']); - } - - // Execute beforeStateUpdated callback if exists - if ($columnConfig instanceof ToggleColumn) { - $beforeCallback = $columnConfig->getBeforeStateUpdated(); - if ($beforeCallback) { - $beforeCallback($record, $column, $value); - } - } - - // Update the record - $record->update([$column => $value]); - - // Execute afterStateUpdated callback if exists - if ($columnConfig instanceof ToggleColumn) { - $afterCallback = $columnConfig->getAfterStateUpdated(); - if ($afterCallback) { - $afterCallback($record, $column, $value); - } - } - - return back(); + // Only editable, non-disabled columns of the resource table can be written, on a tenant-scoped record the + // user may update, with the column's validation rules (see ColumnStateController) + Route::patch($slug.'/{id}/column', function () use ($resourceClass) { + return app(Http\Controllers\ColumnStateController::class)->updateResourceColumn(request(), $resourceClass); })->name('resources.'.$slug.'.column.update'); } @@ -1082,50 +1033,10 @@ protected function registerRelationManagerRoutes(string $resourceClass, string $ })->name('resources.'.$slug.'.relations.bulk-delete'); // Route: PATCH /{slug}/{id}/relations/{relationship}/{relationId}/column - Update single column (for toggle columns) - Route::patch($slug.'/{id}/relations/{relationship}/{relationId}/column', function () use ($resourceClass, $modelClass) { - // Use named route parameters to handle subdomain routes where {tenant} is also a parameter - $id = request()->route('id'); - $relationship = request()->route('relationship'); - $relationId = request()->route('relationId'); - $record = $modelClass::findOrFail($id); - - // Find the relation manager class - $relationManagers = $resourceClass::getRelations(); - $relationManagerClass = null; - - foreach ($relationManagers as $rmClass) { - if ($rmClass::getRelationship() === $relationship) { - $relationManagerClass = $rmClass; - break; - } - } - - if (! $relationManagerClass) { - if (request()->wantsJson()) { - return response()->json(['error' => 'Relation manager not found'], 404); - } - - return back()->withErrors(['error' => 'Relation manager not found']); - } - - // Find and update the related record's column - $relatedRecord = $record->{$relationship}()->findOrFail($relationId); - $column = request()->input('column'); - $value = request()->input('value'); - - if ($column) { - $relatedRecord->update([$column => $value]); - } - - // Return JSON for AJAX requests, redirect for Inertia - if (request()->wantsJson() && ! request()->header('X-Inertia')) { - return response()->json([ - 'success' => true, - 'data' => $relatedRecord, - ]); - } - - return back(); + // Only editable, non-disabled columns of the relation manager's table can be written, on a related record of + // the tenant-scoped owner, when the user may edit it (see ColumnStateController) + Route::patch($slug.'/{id}/relations/{relationship}/{relationId}/column', function () use ($resourceClass) { + return app(Http\Controllers\ColumnStateController::class)->updateRelationColumn(request(), $resourceClass); })->name('resources.'.$slug.'.relations.column'); } diff --git a/tests/Feature/ColumnUpdateRouteTest.php b/tests/Feature/ColumnUpdateRouteTest.php new file mode 100644 index 0000000..d393a30 --- /dev/null +++ b/tests/Feature/ColumnUpdateRouteTest.php @@ -0,0 +1,355 @@ +hasMany(ColumnTestComment::class, 'post_id'); + } +} + +class ColumnTestComment extends Model +{ + protected $table = 'comments'; + + protected $guarded = []; +} + +class ColumnTestCommentsRelationManager extends RelationManager +{ + protected static string $relationship = 'comments'; + + public static bool $readOnly = false; + + public function isReadOnly(): bool + { + return static::$readOnly; + } + + public function table(Table $table): Table + { + return $table->columns([ + TextColumn::make('body'), + ToggleColumn::make('is_approved'), + ]); + } +} + +class ColumnTestPostResource extends Resource +{ + protected static string $model = ColumnTestPost::class; + + protected static ?string $slug = 'posts'; + + /** Simulates the resource's update authorization (permission / policy) */ + public static bool $allowUpdate = true; + + /** Simulates tenant scoping: only records of this team are visible */ + public static ?int $teamId = null; + + /** @var array */ + public static array $callbacks = []; + + public static function canUpdate(?Model $record = null): bool + { + return static::$allowUpdate; + } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + return static::$teamId === null ? $query : $query->where('team_id', static::$teamId); + } + + public static function getRelations(): array + { + return [ColumnTestCommentsRelationManager::class]; + } + + public static function table(Table $table): Table + { + return $table->columns([ + TextColumn::make('title'), + ToggleColumn::make('is_published') + ->beforeStateUpdated(function ($record, $column, $value) { + static::$callbacks[] = ['before', $value]; + }) + ->afterStateUpdated(function ($record, $column, $value) { + static::$callbacks[] = ['after', $value]; + }), + ToggleColumn::make('is_locked')->disabled(), + SelectColumn::make('status')->options(['draft' => 'Draft', 'live' => 'Live']), + ]); + } +} + +beforeEach(function () { + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->string('password'); + $table->timestamps(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->unsignedBigInteger('team_id')->nullable(); + $table->boolean('is_published')->default(false); + $table->boolean('is_locked')->default(false); + $table->string('status')->default('draft'); + $table->boolean('is_admin')->default(false); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('post_id'); + $table->string('body'); + $table->boolean('is_approved')->default(false); + $table->boolean('is_admin')->default(false); + $table->timestamps(); + }); + + ColumnTestPostResource::$allowUpdate = true; + ColumnTestPostResource::$teamId = null; + ColumnTestPostResource::$callbacks = []; + ColumnTestCommentsRelationManager::$readOnly = false; + + // Register the real panel route closures (the same code the panel registers for every resource) + $provider = app()->getProvider(PanelServiceProvider::class); + $panel = Panel::make('admin'); + + Route::middleware('web')->prefix('admin')->group(function () use ($provider, $panel) { + (function () use ($panel) { + $this->registerColumnUpdateRoute(ColumnTestPostResource::class, 'posts', ColumnTestPost::class, $panel); + $this->registerRelationManagerRoutes(ColumnTestPostResource::class, 'posts', ColumnTestPost::class, $panel); + })->call($provider); + }); + + $this->actingAs(ColumnTestUser::create(['name' => 'User', 'email' => 'user@example.com', 'password' => 'secret'])); + + $this->post = ColumnTestPost::create(['title' => 'Hello', 'team_id' => 1]); + $this->comment = $this->post->comments()->create(['body' => 'Nice']); +}); + +describe('resource column route', function () { + it('lets an authorized user toggle an editable column', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertOk() + ->assertJson(['success' => true, 'column' => 'is_published']); + + expect($this->post->fresh()->is_published)->toBe(1) + ->and(ColumnTestPostResource::$callbacks)->toBe([['before', true], ['after', true]]); + }); + + it('redirects back for Inertia requests', function () { + $this->from('/admin/posts') + ->patch('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1], ['X-Inertia' => 'true']) + ->assertRedirect('/admin/posts'); + + expect((bool) $this->post->fresh()->is_published)->toBeTrue(); + }); + + it('returns 403 when the user may not update the record', function () { + ColumnTestPostResource::$allowUpdate = false; + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertForbidden(); + + expect((bool) $this->post->fresh()->is_published)->toBeFalse(); + }); + + it('rejects a non-editable text column', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'title', 'value' => 'Hacked']) + ->assertForbidden(); + + expect($this->post->fresh()->title)->toBe('Hello'); + }); + + it('rejects a column that is not in the table', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_admin', 'value' => 1]) + ->assertNotFound(); + + expect((bool) $this->post->fresh()->is_admin)->toBeFalse(); + }); + + it('rejects a disabled column', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_locked', 'value' => 1]) + ->assertForbidden(); + }); + + it('rejects a missing column name', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['value' => 1]) + ->assertStatus(422); + }); + + it('rejects a non-boolean value on a toggle', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 'yes please']) + ->assertStatus(422) + ->assertJsonValidationErrors('is_published'); + + expect((bool) $this->post->fresh()->is_published)->toBeFalse(); + }); + + it('only accepts the allowed options on a select column', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'status', 'value' => 'deleted']) + ->assertStatus(422); + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'status', 'value' => 'live']) + ->assertOk(); + + expect($this->post->fresh()->status)->toBe('live'); + })->skip(fn () => ! interface_exists(EditableColumn::class), 'requires laravilt/tables with EditableColumn'); + + it('only writes the column attribute', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1, 'is_admin' => 1, 'title' => 'x']) + ->assertOk(); + + $post = $this->post->fresh(); + expect((bool) $post->is_admin)->toBeFalse()->and($post->title)->toBe('Hello'); + }); + + it('loads the record through the resource query (tenant scoping)', function () { + ColumnTestPostResource::$teamId = 2; + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertNotFound(); + + expect((bool) $this->post->fresh()->is_published)->toBeFalse(); + }); +}); + +describe('relation manager column route', function () { + function relationColumnUrl($test): string + { + return '/admin/posts/'.$test->post->id.'/relations/comments/'.$test->comment->id.'/column'; + } + + it('lets an authorized user toggle an editable relation column', function () { + $this->patchJson(relationColumnUrl($this), ['column' => 'is_approved', 'value' => true]) + ->assertOk() + ->assertJson(['success' => true]); + + expect((bool) $this->comment->fresh()->is_approved)->toBeTrue(); + }); + + it('rejects an arbitrary attribute', function () { + $this->patchJson(relationColumnUrl($this), ['column' => 'is_admin', 'value' => 1]) + ->assertNotFound(); + + $this->patchJson(relationColumnUrl($this), ['column' => 'post_id', 'value' => 999]) + ->assertNotFound(); + + $this->patchJson(relationColumnUrl($this), ['column' => 'body', 'value' => 'Hacked']) + ->assertForbidden(); + + $comment = $this->comment->fresh(); + expect((bool) $comment->is_admin)->toBeFalse() + ->and($comment->post_id)->toBe($this->post->id) + ->and($comment->body)->toBe('Nice'); + }); + + it('rejects a non-boolean value', function () { + $this->patchJson(relationColumnUrl($this), ['column' => 'is_approved', 'value' => 'abc']) + ->assertStatus(422); + }); + + it('returns 403 when the owner record may not be updated', function () { + ColumnTestPostResource::$allowUpdate = false; + + $this->patchJson(relationColumnUrl($this), ['column' => 'is_approved', 'value' => 1]) + ->assertForbidden(); + }); + + it('returns 403 when the relation manager is read-only', function () { + ColumnTestCommentsRelationManager::$readOnly = true; + + $this->patchJson(relationColumnUrl($this), ['column' => 'is_approved', 'value' => 1]) + ->assertForbidden(); + }); + + it('rejects a related record that does not belong to the owner', function () { + $other = ColumnTestPost::create(['title' => 'Other']); + $foreign = $other->comments()->create(['body' => 'Foreign']); + + $this->patchJson('/admin/posts/'.$this->post->id.'/relations/comments/'.$foreign->id.'/column', ['column' => 'is_approved', 'value' => 1]) + ->assertNotFound(); + + expect((bool) $foreign->fresh()->is_approved)->toBeFalse(); + }); +}); + +describe('legacy laravilt/tables without the EditableColumn contract', function () { + beforeEach(function () { + // Simulates tables 1.0.x, where EditableColumn / getStateValidationRules() do not exist + app()->bind(ColumnStateController::class, fn () => new class extends ColumnStateController + { + protected function supportsEditableColumns(): bool + { + return false; + } + }); + }); + + it('still lets a toggle column be updated with a boolean', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertOk(); + + expect((bool) $this->post->fresh()->is_published)->toBeTrue() + ->and(ColumnTestPostResource::$callbacks)->toBe([['before', true], ['after', true]]); + }); + + it('rejects a non-boolean toggle value', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 'abc']) + ->assertStatus(422); + }); + + it('only allows toggle columns', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'status', 'value' => 'live']) + ->assertForbidden(); + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'title', 'value' => 'x']) + ->assertForbidden(); + + expect($this->post->fresh()->status)->toBe('draft'); + }); + + it('still requires update authorization', function () { + ColumnTestPostResource::$allowUpdate = false; + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertForbidden(); + }); +});