Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
7.1 KiB
PHP
182 lines
7.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Support;
|
|
|
|
use App\Models\User;
|
|
use App\Support\ReviewTarget;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\BulkAction;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Notifications\Notification;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
|
/**
|
|
* The two buttons a review is made of.
|
|
*
|
|
* One class for pages, projects and services, because the difference between
|
|
* reviewing them is nothing: somebody who cannot publish says "this is ready",
|
|
* and somebody who can either publishes it or sends it back with a reason.
|
|
*
|
|
* Note which permissions they ask for. Asking for a review needs no permission
|
|
* of its own — being able to edit the thing is already the right to say you
|
|
* have finished with it. Answering one asks for `publish`, which is the
|
|
* permission that made the whole handover necessary.
|
|
*/
|
|
final class ReviewActions
|
|
{
|
|
/**
|
|
* "This is ready." Offered to whoever can edit the record and is not
|
|
* already able to publish it — for somebody holding both, the button
|
|
* would be a detour around a decision they are allowed to make.
|
|
*/
|
|
public static function request(): Action
|
|
{
|
|
return Action::make('request_review')
|
|
->label(__('panel.reviews.actions.request'))
|
|
->icon('heroicon-o-hand-raised')
|
|
->color('warning')
|
|
->visible(fn (Model $record) => ! $record->isAwaitingReview()
|
|
&& ! $record->isPublishedNow()
|
|
&& Auth::user()?->can('update', $record)
|
|
&& Auth::user()?->cannot(ReviewTarget::permission($record)))
|
|
->modalHeading(__('panel.reviews.actions.request_heading'))
|
|
->modalDescription(__('panel.reviews.actions.request_hint'))
|
|
->schema([
|
|
Select::make('reviewer_id')
|
|
->label(__('panel.reviews.fields.reviewer'))
|
|
->helperText(__('panel.reviews.fields.reviewer_hint'))
|
|
->options(fn (Model $record) => self::reviewers($record))
|
|
// Nobody in particular is a real answer, and the common
|
|
// one: the author usually has no idea who is free.
|
|
->placeholder(__('panel.reviews.anyone')),
|
|
|
|
Textarea::make('note')
|
|
->label(__('panel.reviews.fields.note'))
|
|
->helperText(__('panel.reviews.fields.note_hint'))
|
|
->rows(3)
|
|
->maxLength(2000),
|
|
])
|
|
->action(function (Model $record, array $data): void {
|
|
$record->requestReview(
|
|
$data['reviewer_id'] ? User::find($data['reviewer_id']) : null,
|
|
$data['note'] ?: null,
|
|
);
|
|
|
|
Notification::make()
|
|
->title(__('panel.reviews.requested'))
|
|
->success()
|
|
->send();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The same "this is ready", for a whole selection at once.
|
|
*
|
|
* The visibility check reads the raw permission string rather than
|
|
* asking the model's policy, and deliberately so: `update` on a page is
|
|
* not a plain permission check, it also lets an author edit their own
|
|
* draft without the blanket ability — a nuance that means something for
|
|
* one record and nothing for a bulk action, and a policy call with no
|
|
* record to test it against does not even have a record to fall back to.
|
|
* The permission behind the ability is what a role actually holds, which
|
|
* is the question a button's visibility needs answered.
|
|
*
|
|
* What does vary per record is whether asking makes sense at all, so the
|
|
* action itself quietly skips anything already awaiting review or
|
|
* already live rather than erroring on it — a selection made across a
|
|
* whole page of mixed states is the normal case, not an edge one.
|
|
*
|
|
* @param class-string<Model> $model
|
|
*/
|
|
public static function requestBulk(string $model): BulkAction
|
|
{
|
|
$table = (new $model)->getTable();
|
|
|
|
return BulkAction::make('request_review_bulk')
|
|
->label(__('panel.reviews.actions.request_bulk'))
|
|
->icon('heroicon-o-hand-raised')
|
|
->color('warning')
|
|
->visible(fn () => (Auth::user()?->can("{$table}.update") ?? false)
|
|
&& (Auth::user()?->cannot("{$table}.publish") ?? true))
|
|
->deselectRecordsAfterCompletion()
|
|
->action(function (Collection $records): void {
|
|
$requested = 0;
|
|
|
|
foreach ($records as $record) {
|
|
if ($record->isAwaitingReview() || $record->isPublishedNow()) {
|
|
continue;
|
|
}
|
|
|
|
$record->requestReview();
|
|
$requested++;
|
|
}
|
|
|
|
Notification::make()
|
|
->title(trans_choice('panel.reviews.requested_count', $requested, ['count' => $requested]))
|
|
->success()
|
|
->send();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* "Not yet, and here is why." The reason is required: work handed back
|
|
* without one is what turns a review step into something people route
|
|
* around.
|
|
*/
|
|
public static function returnForChanges(): Action
|
|
{
|
|
return Action::make('return_review')
|
|
->label(__('panel.reviews.actions.return'))
|
|
->icon('heroicon-o-arrow-uturn-left')
|
|
->color('danger')
|
|
->visible(fn (Model $record) => $record->isAwaitingReview()
|
|
&& Auth::user()?->can(ReviewTarget::permission($record)))
|
|
->modalHeading(__('panel.reviews.actions.return_heading'))
|
|
->schema([
|
|
Textarea::make('note')
|
|
->label(__('panel.reviews.fields.reason'))
|
|
->helperText(__('panel.reviews.fields.reason_hint'))
|
|
->rows(3)
|
|
->required()
|
|
->maxLength(2000),
|
|
])
|
|
->action(function (Model $record, array $data): void {
|
|
$record->returnForChanges($data['note']);
|
|
|
|
Notification::make()
|
|
->title(__('panel.reviews.returned'))
|
|
->success()
|
|
->send();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Who could actually answer this, as a list to choose from.
|
|
*
|
|
* Resolved through `can()` rather than a permission query so super admins
|
|
* appear: they hold no explicit permissions, only the gate that grants
|
|
* them everything, and leaving them out of the list would hide the person
|
|
* most likely to be able to help.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
protected static function reviewers(Model $record): array
|
|
{
|
|
$permission = ReviewTarget::permission($record);
|
|
|
|
return User::query()
|
|
->where('is_active', true)
|
|
->whereKeyNot(Auth::id())
|
|
->orderBy('name')
|
|
->get()
|
|
->filter(fn (User $user) => $user->can($permission))
|
|
->pluck('name', 'id')
|
|
->all();
|
|
}
|
|
}
|