mokhosh / filament-kanban

Add kanban boards to your Filament pages
https://filamentphp.com/plugins/mokhosh-kanban
MIT License
227 stars 32 forks source link

[Request]: Allow a Kanban board per resource record #33

Open ryanmortier opened 3 months ago

ryanmortier commented 3 months ago

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();
        });
    }
ryanmortier commented 3 months ago

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.

jalexmelendez commented 3 months ago

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

ahmetkocabiyik commented 3 months ago

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 ?

ryanmortier commented 3 months ago

@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();
    }
}