Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,19 @@
],
"require": {
"php": "^8.3",
"filament/filament": "^5.0",
"spatie/laravel-package-tools": "^1.15.0",
"ext-bcmath": "*"
"ext-bcmath": "*",
"filament/actions": "^5.0",
"filament/forms": "^5.0",
"filament/schemas": "^5.0",
"filament/support": "^5.0",
"filament/tables": "^5.0",
"spatie/laravel-package-tools": "^1.15.0"
},
"suggest": {
"filament/filament": "Required for BoardPage, BoardResourcePage, and FlowforgePlugin panel integration (^5.0)"
},
"require-dev": {
"filament/filament": "^5.0",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.0",
"nunomaduro/collision": "^8.0",
Expand Down
5 changes: 5 additions & 0 deletions src/BoardPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
/**
* Board page for standard Filament pages.
* Extends Filament's base Page class with kanban board functionality.
*
* Requires the `filament/filament` package (Panel Builder).
* For standalone usage, use the InteractsWithBoard trait directly.
*
* @see \Relaticle\Flowforge\Concerns\InteractsWithBoard
*/
abstract class BoardPage extends Page implements HasActions, HasBoard, HasForms
{
Expand Down
5 changes: 5 additions & 0 deletions src/BoardResourcePage.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@
* Board page for Filament resource pages.
* Extends Filament's resource Page class with kanban board functionality.
*
* Requires the `filament/filament` package (Panel Builder).
* For standalone usage, use the InteractsWithBoard trait directly.
*
* CRITICAL: This class doesn't use InteractsWithRecord trait itself, but child
* classes might. To handle the trait conflict, we override getDefaultActionRecord()
* to intelligently route to either board card records or resource records based
* on whether a recordKey is present in the mounted action context.
*
* @see \Relaticle\Flowforge\Concerns\InteractsWithBoard
*/
abstract class BoardResourcePage extends Page implements HasActions, HasBoard, HasForms
{
Expand Down
16 changes: 12 additions & 4 deletions src/FlowforgePlugin.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
<?php

declare(strict_types=1);

namespace Relaticle\Flowforge;

use Filament\Contracts\Plugin;
use Filament\Panel;
use Livewire\Livewire;

// use Relaticle\Flowforge\Livewire\KanbanBoard;

/**
* Filament Panel plugin for FlowForge.
*
* This class requires the full `filament/filament` package (Panel Builder).
* For standalone Livewire usage without a panel, use the InteractsWithBoard
* trait directly on your Livewire component instead.
*
* @see \Relaticle\Flowforge\Concerns\InteractsWithBoard
*/
class FlowforgePlugin implements Plugin
{
public function getId(): string
Expand All @@ -22,7 +30,7 @@ public function register(Panel $panel): void

public function boot(Panel $panel): void
{
// Livewire::component('relaticle.flowforge.livewire.kanban-board', KanbanBoard::class);
//
}

public static function make(): static
Expand Down
File renamed without changes.
50 changes: 50 additions & 0 deletions tests/Fixtures/TestStandaloneBoard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Relaticle\Flowforge\Tests\Fixtures;

use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Livewire\Component;
use Relaticle\Flowforge\Board;
use Relaticle\Flowforge\Column;
use Relaticle\Flowforge\Concerns\InteractsWithBoard;
use Relaticle\Flowforge\Contracts\HasBoard;

/**
* Standalone Livewire component for testing without Filament Panel.
*/
class TestStandaloneBoard extends Component implements HasActions, HasBoard, HasForms
{
use InteractsWithActions;
use InteractsWithBoard {
InteractsWithBoard::getDefaultActionRecord insteadof InteractsWithActions;
}
use InteractsWithForms;

public function board(Board $board): Board
{
return $board
->query(Task::query())
->recordTitleAttribute('title')
->columnIdentifier('status')
->positionIdentifier('order_position')
->columns([
Column::make('todo')->label('To Do')->color('gray'),
Column::make('in_progress')->label('In Progress')->color('blue'),
Column::make('completed')->label('Completed')->color('green'),
]);
}

public function render()
{
return <<<'BLADE'
<div>
{{ $this->board }}
</div>
BLADE;
}
}
4 changes: 3 additions & 1 deletion tests/Pest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

use Relaticle\Flowforge\Tests\StandaloneTestCase;
use Relaticle\Flowforge\Tests\TestCase;

pest()->extends(TestCase::class)->in(__DIR__);
pest()->extends(TestCase::class)->in('Feature', 'Unit');
pest()->extends(StandaloneTestCase::class)->in('Standalone');
81 changes: 81 additions & 0 deletions tests/Standalone/StandaloneBoardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

use Livewire\Livewire;
use Relaticle\Flowforge\Services\DecimalPosition;
use Relaticle\Flowforge\Tests\Fixtures\Task;
use Relaticle\Flowforge\Tests\Fixtures\TestStandaloneBoard;

/**
* @see https://github.com/relaticle/flowforge/issues/84
*/
describe('standalone board rendering', function () {
test('renders board with all columns', function () {
Livewire::test(TestStandaloneBoard::class)
->assertStatus(200)
->assertSee('To Do')
->assertSee('In Progress')
->assertSee('Completed');
});

test('displays cards in correct columns', function () {
Task::factory()->todo()->create(['title' => 'Standalone Todo']);
Task::factory()->inProgress()->create(['title' => 'Standalone In Progress']);
Task::factory()->completed()->create(['title' => 'Standalone Completed']);

Livewire::test(TestStandaloneBoard::class)
->assertSee('Standalone Todo')
->assertSee('Standalone In Progress')
->assertSee('Standalone Completed');
});
});

describe('standalone card movement', function () {
test('moves card to different column', function () {
$task = Task::factory()->todo()->withPosition('65535.0000000000')->create();

Livewire::test(TestStandaloneBoard::class)
->call('moveCard', (string) $task->id, 'in_progress', null, null)
->assertDispatched('kanban-card-moved');

expect($task->fresh()->status)->toBe('in_progress');
});

test('moves card between two cards', function () {
$task1 = Task::factory()->inProgress()->withPosition('65535.0000000000')->create();
$task2 = Task::factory()->inProgress()->withPosition('131070.0000000000')->create();
$taskToMove = Task::factory()->todo()->withPosition('65535.0000000000')->create();

Livewire::test(TestStandaloneBoard::class)
->call('moveCard', (string) $taskToMove->id, 'in_progress', (string) $task1->id, (string) $task2->id)
->assertDispatched('kanban-card-moved');

$movedTask = $taskToMove->fresh();
expect($movedTask->status)->toBe('in_progress')
->and((float) $movedTask->order_position)->toBeGreaterThan(65535)
->and((float) $movedTask->order_position)->toBeLessThan(131070);
});

test('moves card to empty column', function () {
$task = Task::factory()->todo()->withPosition('65535.0000000000')->create();

Livewire::test(TestStandaloneBoard::class)
->call('moveCard', (string) $task->id, 'completed', null, null)
->assertDispatched('kanban-card-moved');

$movedTask = $task->fresh();
expect($movedTask->status)->toBe('completed')
->and((float) $movedTask->order_position)->toBe((float) DecimalPosition::DEFAULT_GAP);
});
});

describe('standalone pagination', function () {
test('loads more items on demand', function () {
Task::factory(30)->todo()->create();

Livewire::test(TestStandaloneBoard::class)
->call('loadMoreItems', 'todo', 20)
->assertDispatched('kanban-items-loaded');
});
});
103 changes: 103 additions & 0 deletions tests/StandaloneTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace Relaticle\Flowforge\Tests;

use BladeUI\Heroicons\BladeHeroiconsServiceProvider;
use BladeUI\Icons\BladeIconsServiceProvider;
use Filament\Actions\ActionsServiceProvider;
use Filament\FilamentManager;
use Filament\Forms\FormsServiceProvider;
use Filament\Infolists\InfolistsServiceProvider;
use Filament\Notifications\NotificationsServiceProvider;
use Filament\Support\SupportServiceProvider;
use Filament\Tables\TablesServiceProvider;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Livewire\LivewireServiceProvider;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase as Orchestra;
use Relaticle\Flowforge\FlowforgeServiceProvider;
use RyanChandler\BladeCaptureDirective\BladeCaptureDirectiveServiceProvider;

/**
* Test case for standalone Livewire usage WITHOUT the Filament Panel Builder.
*
* Excludes FilamentServiceProvider and TestPanelProvider to verify the package
* works without a panel registered.
*
* Note: In a real standalone install (without filament/filament), the Filament
* facade class won't exist and class_exists() guards in filament/tables skip
* panel-dependent calls entirely. In our test environment, filament/filament IS
* installed as a dev dependency, so we register a minimal FilamentManager binding
* to satisfy those guards without configuring any panel.
*/
class StandaloneTestCase extends Orchestra
{
use LazilyRefreshDatabase;
use WithWorkbench;

protected function setUp(): void
{
parent::setUp();

Factory::guessFactoryNamesUsing(
fn (string $modelName) => 'Relaticle\\Flowforge\\Database\\Factories\\' . class_basename($modelName) . 'Factory'
);

// Register minimal FilamentManager binding. This is only needed because
// filament/filament is a dev dependency (for panel tests), making the
// Filament facade class available. The tables package's HasFilters trait
// uses class_exists(Filament::class) to guard tenant-aware session keys,
// which passes in our test env but would be false in a real standalone install.
if (! $this->app->bound('filament')) {
$this->app->scoped('filament', fn () => new FilamentManager);
}
}

protected function getPackageProviders($app): array
{
$providers = [
ActionsServiceProvider::class,
BladeCaptureDirectiveServiceProvider::class,
BladeHeroiconsServiceProvider::class,
BladeIconsServiceProvider::class,
FormsServiceProvider::class,
InfolistsServiceProvider::class,
LivewireServiceProvider::class,
NotificationsServiceProvider::class,
SupportServiceProvider::class,
TablesServiceProvider::class,
FlowforgeServiceProvider::class,
];

sort($providers);

return $providers;
}

protected function defineEnvironment($app): void
{
config()->set('database.default', 'testing');
config()->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);

config()->set('app.key', 'base64:' . base64_encode(random_bytes(32)));
config()->set('session.driver', 'array');
config()->set('session.encrypt', false);

config()->set('view.paths', [
resource_path('views'),
__DIR__ . '/../resources/views',
]);
}

protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/database/migrations');
}
}