filament-kanban
filament-kanban copied to clipboard
[Request]: Allow a Kanban board per resource record
What happened?
I'm looking to be able to dynamically show a Kanban board per resource record. Take for instance the following example:
A Campaign
model has a belongsToMany
relationship to an Account
model.
A Campaign
also has a hasMany
relationship to a CampaignStage
model.
The flow from a user would be that they create a campaign and attach accounts to the campaign. An account could potentially be added to many other campaigns. The user would also add a list of stages to each campaign and therefore each campaign may have a different set of stages (statuses).
On the API side, I would like to add a page to the CampaignResource::class
and add the page to the getPages()
method which would allow the page to receive the record via the InteractsWithRecords
trait. When a user clicks the table record on the list page, they would be linked to the kanban board with the record passed in the route.
Also, since the campaigns and accounts are a many-to-many relationship, the status column cannot exist on the records (in this case the accounts table) and must instead be on the pivot table.
I tried my best to work around the package as it currently stands.
One suggestion you had made in #10 is to use a query string. The problem here is that Livewire can only get the request on mount, and not subsequent requests. Therefore the query string is null on subsequent requests.
This is my code:
Page
class CampaignKanbanBoard extends KanbanBoard
{
protected static string $model = Account::class;
protected static ?string $slug = '/campaigns/board';
protected static string $recordTitleAttribute = 'name';
protected Campaign $campaign;
public function boot(): void
{
$this->campaign = Campaign::findOrFail(request()->query('id'));
}
public function getHeading(): string|Htmlable
{
return $this->campaign->name;
}
public function onStatusChanged(int $recordId, string $status, array $fromOrderedIds, array $toOrderedIds): void
{
ray($recordId, $status, $fromOrderedIds, $toOrderedIds);
}
public function onSortChanged(int $recordId, string $status, array $orderedIds): void
{
ray($recordId, $status, $orderedIds);
}
protected function records(): Collection
{
return $this->campaign->accounts()->with('campaigns.stages')->get();
}
protected function statuses(): Collection
{
$new = ['id' => 0, 'title' => 'New'];
return $this->campaign->stages->map(function ($stage) {
return ['id' => $stage->id, 'title' => $stage->name];
})->prepend($new);
}
protected function filterRecordsByStatus(Collection $records, array $status): array
{
return $records->where('pivot.campaign_stage_id', '=', $status['id'])->all();
}
}
Models
class Campaign extends Model
{
public function stages(): HasMany
{
return $this->hasMany(CampaignStage::class);
}
public function accounts(): BelongsToMany
{
return $this->belongsToMany(Account::class, 'crm.account_campaign')->withPivot('campaign_stage_id')->withTimestamps();
}
}
class Account extends Model
{
public function campaigns(): BelongsToMany
{
return $this->belongsToMany(Campaign::class, 'crm.account_campaign')->withPivot('campaign_stage_id')->withTimestamps();
}
}
class CampaignStage extends Model implements Sortable
{
use SortableTrait;
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class);
}
}
Migrations
public function up(): void
{
Schema::create('campaigns', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
Schema::create('campaign_stages', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->smallInteger('sort_order');
$table->foreignIdFor(Campaign::class)->constrained();
$table->timestamps();
});
Schema::create('account_campaign', function (Blueprint $table) {
$table->id();
$table->smallInteger('sort_order');
$table->foreignIdFor(CampaignStage::class)->constrained();
$table->foreignIdFor(Account::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(Campaign::class)->constrained()->cascadeOnDelete();
$table->timestamps();
});
}
I think I've managed to get it to work. I was using a protected property rather than a public property for my campaign and so it wasn't being available on subsequent requests.
I have some source code implementing a kanban per resource, if you are interested we can work in a PR request to install either the board globally or in a resource
Hi @ryanmortier i used your code to replicate same scenario. When i type an id for campaign, it works great but with query string there is a problem on subsequent requests. Query string return null. Did you find a way to solve query string problem ?
@ahmetkocabiyik yes you'll need to change your query string variable from protected
to public
for Livewire to persist it through subsequent requests. Here is an updated version of my page, take from it what you need:
<?php
namespace App\Filament\Crm\Pages;
use App\Filament\Crm\Resources\CampaignResource;
use App\Models\Crm\Account;
use App\Models\Crm\AccountCampaign;
use App\Models\Crm\Campaign;
use App\Models\Crm\CampaignStage;
use Filament\Actions;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\HtmlString;
use Mokhosh\FilamentKanban\Pages\KanbanBoard;
class CampaignKanbanBoard extends KanbanBoard
{
protected static ?string $slug = 'campaigns/board';
protected static string $model = Account::class;
protected static string $recordTitleAttribute = 'name';
protected static string $recordStatusAttribute = 'sort_order';
protected static bool $shouldRegisterNavigation = false;
public bool $disableEditModal = true;
public Campaign $campaign;
public function mount(): void
{
parent::mount();
$this->campaign = Campaign::findOrFail(request()->query('id'));
}
public function getBreadcrumbs(): array
{
return [
route('filament.crm.resources.campaigns.index') => 'Campaigns',
route('filament.crm.pages.campaigns.board', ['id' => $this->campaign->id]) => $this->campaign->name,
'Board',
];
}
public function getHeading(): string|Htmlable
{
return $this->campaign->name;
}
public function getSubheading(): string|Htmlable|null
{
$subheading = '<strong>'.e($this->campaign->start->format('M j, Y')).'</strong>';
$subheading .= ' to ';
$subheading .= '<strong>'.e($this->campaign->end->format('M j, Y')).'</strong>';
if ($this->campaign->description) {
$subheading .= '<br><p class="text-gray-600 dark:text-gray-400">'.nl2br(e($this->campaign->description)).'</p>';
}
return new HtmlString($subheading);
}
public function onStatusChanged(int $recordId, string $status, array $fromOrderedIds, array $toOrderedIds): void
{
$stage = CampaignStage::find($status);
$pivot = AccountCampaign::query()
->where('campaign_id', $this->campaign->id)
->where('account_id', $recordId)
->first();
if (! $pivot) {
return;
}
if ($stage) {
$pivot->stage()->associate($stage);
} else {
$pivot->stage()->dissociate();
}
$pivot->save();
$this->changeSortOrder($toOrderedIds);
}
protected function changeSortOrder(array $ids): void
{
AccountCampaign::setNewOrder(
$ids,
1,
'account_id',
function (Builder $query): Builder {
return $query->where('campaign_id', '=', $this->campaign->id);
}
);
}
public function onSortChanged(int $recordId, string $status, array $orderedIds): void
{
$this->changeSortOrder($orderedIds);
}
protected function records(): Collection
{
return $this->campaign
->accounts()
->orderByPivot('sort_order')
->orderBy('name')
->get();
}
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make()
->url(fn (): string => CampaignResource::getUrl(
'edit',
['record' => $this->campaign]
)),
];
}
protected function statuses(): Collection
{
$new = ['id' => 0, 'title' => 'New'];
return $this->campaign->stages()->ordered()->get()->map(function ($stage) {
return ['id' => $stage->id, 'title' => $stage->name];
})->prepend($new);
}
protected function filterRecordsByStatus(Collection $records, array $status): array
{
return $records->where('pivot.campaign_stage_id', '=', $status['id'])->all();
}
}