Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
ryanmortier opened this issue Apr 10, 2024 · 4 comments
Open

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

ryanmortier opened this issue Apr 10, 2024 · 4 comments
Labels
good first issue Good for newcomers v3 Planned for v3

Comments

@ryanmortier
Copy link
Contributor

ryanmortier commented Apr 10, 2024

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 ryanmortier added the bug Something isn't working label Apr 10, 2024
@ryanmortier
Copy link
Contributor Author

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.

@mokhosh mokhosh added good first issue Good for newcomers v3 Planned for v3 and removed bug Something isn't working labels Apr 11, 2024
@jalexmelendez
Copy link

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
Copy link

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
Copy link
Contributor Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
good first issue Good for newcomers v3 Planned for v3
Projects
None yet
Development

No branches or pull requests

4 participants