Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions src/Http/Controllers/ColumnStateController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
<?php

namespace Laravilt\Panel\Http\Controllers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Laravilt\Panel\Resources\RelationManagers\RelationManager;
use Laravilt\Tables\Columns\Contracts\EditableColumn;
use Laravilt\Tables\Columns\ToggleColumn;
use Laravilt\Tables\Table;

/**
* Persists a single inline-edited column value (ToggleColumn, CheckboxColumn, SelectColumn, TextInputColumn)
* for resource tables and relation manager tables.
*
* Only a column the developer declared as editable (and not disabled) in the relevant table can be written,
* only to its own attribute, validated with its rules, on a tenant-scoped record the user may update.
*
* laravilt/tables releases without the EditableColumn contract are still supported: in that case only
* ToggleColumn is editable and the value must be a boolean.
*/
class ColumnStateController
{
/**
* PATCH {slug}/{id}/column
*
* @param class-string $resourceClass
*/
public function updateResourceColumn(Request $request, string $resourceClass): mixed
{
$column = $this->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);
}
}
}
105 changes: 8 additions & 97 deletions src/PanelServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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');
}

Expand Down
Loading