Created
August 24, 2025 14:30
-
-
Save ManukMinasyan/5b62aab8408aa7a2be7926a05f138b23 to your computer and use it in GitHub Desktop.
Flowforge - Custom Fields Integration Example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App\Filament\Clusters\Management\Pages; | |
use App\Models\Management\Opportunity; | |
use BackedEnum; | |
use Exception; | |
use Filament\Actions\CreateAction; | |
use Filament\Actions\EditAction; | |
use Filament\Forms\Components\Select; | |
use Filament\Forms\Components\TextInput; | |
use Filament\Infolists\Components\TextEntry; | |
use Filament\Schemas\Schema; | |
use Filament\Support\Enums\Width; | |
use Illuminate\Database\Eloquent\Builder; | |
use Illuminate\Support\Facades\DB; | |
use Relaticle\CustomFields\Facades\CustomFields; | |
use Relaticle\CustomFields\Models\CustomField; | |
use Relaticle\Flowforge\Board; | |
use Relaticle\Flowforge\BoardPage; | |
use Relaticle\Flowforge\Column; | |
use UnitEnum; | |
final class OpportunityBoard extends BoardPage | |
{ | |
protected static ?string $navigationLabel = 'Opportunity Board'; | |
protected static ?string $title = 'Opportunities'; | |
protected static string | null | UnitEnum $navigationGroup = 'Flowforge'; | |
protected static string | null | BackedEnum $navigationIcon = 'heroicon-o-briefcase'; | |
public function board(Board $board): Board | |
{ | |
return $board | |
->query($this->getOpportunityQuery()) | |
->columnIdentifier('cfv.string_value') | |
->positionIdentifier('position') | |
->recordTitleAttribute('title') | |
->columns($this->getDynamicColumns()) | |
->columnActions([ | |
CreateAction::make() | |
->label('Add Opportunity') | |
->iconButton() | |
->icon('heroicon-o-plus') | |
->model(Opportunity::class) | |
->modalWidth(Width::Large) | |
->schema([ | |
TextInput::make('title') | |
->required() | |
->columnSpanFull(), | |
Select::make('user_id') | |
->label('Assign to') | |
->relationship('user', 'name') | |
->searchable() | |
->preload() | |
->required() | |
->placeholder('Unassigned') | |
->columnSpanFull(), | |
CustomFields::form()->forModel(Opportunity::class)->except(['status'])->build(), | |
]) | |
->mutateDataUsing(function (array $data, array $arguments): array { | |
if (isset($arguments['column'])) { | |
$data['position'] = $this->getBoardPositionInColumn($arguments['column']); | |
} | |
return $data; | |
}) | |
->after(function (Opportunity $record, array $arguments): void { | |
if (isset($arguments['column'])) { | |
$this->updateCustomFieldStatus($record, $arguments['column']); | |
} | |
}), | |
]) | |
->cardActions([ | |
EditAction::make() | |
->label('Edit') | |
->icon('heroicon-o-pencil') | |
->modalWidth(Width::Large) | |
->schema([ | |
TextInput::make('title') | |
->required() | |
->columnSpanFull(), | |
Select::make('user_id') | |
->label('Assign to') | |
->relationship('user', 'name') | |
->searchable() | |
->preload() | |
->required() | |
->placeholder('Unassigned') | |
->columnSpanFull(), | |
CustomFields::form()->forModel(Opportunity::class)->except(['status'])->build(), | |
]), | |
]) | |
->cardSchema(function (Schema $schema): Schema { | |
return $schema | |
->components([ | |
TextEntry::make('user.name') | |
->hiddenLabel() | |
->badge() | |
->color('gray') | |
->icon('heroicon-o-user') | |
->placeholder('Unassigned'), | |
CustomFields::infolist()->forSchema($schema)->except(['status'])->build(), | |
]); | |
}); | |
} | |
public function moveCard( | |
string $cardId, | |
string $targetColumnId, | |
?string $afterCardId = null, | |
?string $beforeCardId = null | |
): void { | |
$opportunity = Opportunity::query()->findOrFail($cardId); | |
$newPosition = $this->calculatePositionBetweenCards($afterCardId, $beforeCardId, $targetColumnId); | |
// Use transaction for atomicity | |
DB::transaction(function () use ($opportunity, $targetColumnId, $newPosition) { | |
$this->updateCustomFieldStatus($opportunity, $targetColumnId); | |
$opportunity->update(['position' => $newPosition]); | |
}); | |
$this->dispatch('kanban-card-moved', [ | |
'cardId' => $cardId, | |
'columnId' => $targetColumnId, | |
'position' => $newPosition, | |
]); | |
} | |
private function getOpportunityQuery(): Builder | |
{ | |
return Opportunity::query() | |
->with('user', 'customFieldValues.customField') | |
->join('custom_field_values as cfv', 'management_opportunities.id', '=', 'cfv.entity_id') | |
->join('custom_fields as cf', 'cfv.custom_field_id', '=', 'cf.id') | |
->where('cfv.entity_type', Opportunity::class) | |
->where('cf.code', 'status') | |
->select('management_opportunities.*', 'cfv.string_value as board_status'); | |
} | |
private function getStatusCustomField(): ?CustomField | |
{ | |
return CustomField::where('code', 'status') | |
->where('entity_type', Opportunity::class) | |
->first(); | |
} | |
private function updateCustomFieldStatus(Opportunity $opportunity, string $status): void | |
{ | |
$statusField = $this->getStatusCustomField(); | |
if (! $statusField) { | |
return; | |
} | |
DB::table('custom_field_values') | |
->updateOrInsert([ | |
'entity_type' => Opportunity::class, | |
'entity_id' => $opportunity->id, | |
'custom_field_id' => $statusField->id, | |
], [ | |
'string_value' => $status, | |
]); | |
} | |
/** | |
* @return array<Column> | |
* | |
* @throws Exception | |
*/ | |
protected function getDynamicColumns(): array | |
{ | |
$customField = $this->getStatusCustomField(); | |
if (! $customField) { | |
return []; | |
} | |
$options = $customField->options()->orderBy('sort_order')->get(); | |
$columns = []; | |
foreach ($options as $option) { | |
$columns[] = Column::make($option->name) | |
->label($option->name); | |
} | |
return $columns; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment