Skip to content
Open
2 changes: 1 addition & 1 deletion resources/dist/flowforge.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions resources/js/flowforge.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export default function flowforge({state}) {
state,
isLoading: {},
fullyLoaded: {},
collapsedSwimlanes: {},

init() {
this.$wire.$on('kanban-items-loaded', (event) => {
Expand All @@ -12,6 +13,11 @@ export default function flowforge({state}) {
}
});

// Restore collapsed swimlane state from localStorage
if (this.state.swimlanes) {
this._restoreSwimlaneState();
}

this.$nextTick(() => this.enhanceSortableScroll());
},

Expand Down Expand Up @@ -114,5 +120,43 @@ export default function flowforge({state}) {
this.handleSmoothScroll(columnId);
}
},

// --- Swimlane collapse/expand ---

toggleSwimlane(swimlaneId) {
this.collapsedSwimlanes[swimlaneId] = !this.collapsedSwimlanes[swimlaneId];
this._saveSwimlaneState();
},

isSwimlaneCollapsed(swimlaneId) {
return this.collapsedSwimlanes[swimlaneId] || false;
},

_getSwimlaneStorageKey() {
// Use the page URL path as a board-specific key
return 'flowforge:swimlanes:' + window.location.pathname;
},

_saveSwimlaneState() {
try {
localStorage.setItem(
this._getSwimlaneStorageKey(),
JSON.stringify(this.collapsedSwimlanes)
);
} catch (e) {
// localStorage may be unavailable; ignore silently
}
},

_restoreSwimlaneState() {
try {
const stored = localStorage.getItem(this._getSwimlaneStorageKey());
if (stored) {
this.collapsedSwimlanes = JSON.parse(stored);
}
} catch (e) {
this.collapsedSwimlanes = {};
}
},
}
}
1 change: 1 addition & 0 deletions resources/lang/en/flowforge.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
'plural_card_label' => 'Records',
'cards_pagination' => ':current of :total :cards',
'all_cards_loaded' => 'All :total :cards loaded',
'uncategorized' => 'Uncategorized',
];
36 changes: 24 additions & 12 deletions resources/views/index.blade.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
@php use Filament\Support\Facades\FilamentAsset; @endphp
@props(['columns', 'config'])
@props(['columns', 'swimlanes', 'config'])

<div
class="w-full h-full flex flex-col relative"
Expand All @@ -8,8 +8,10 @@ class="w-full h-full flex flex-col relative"
x-data="flowforge({
state: {
columns: @js($columns),
swimlanes: @js($swimlanes),
titleField: '{{ $config['recordTitleAttribute'] }}',
columnField: '{{ $config['columnIdentifierAttribute'] }}',
swimlaneField: '{{ $config['swimlaneIdentifierAttribute'] ?? '' }}',
cardLabel: '{{ $config['cardLabel'] }}',
pluralCardLabel: '{{ $config['pluralCardLabel'] }}',
}
Expand All @@ -19,18 +21,28 @@ class="w-full h-full flex flex-col relative"
@include('flowforge::components.filters')

<!-- Board Content -->
<div class="flex-1 overflow-hidden h-full">
<div class="flex flex-row h-full overflow-x-auto overflow-y-hidden gap-5">
@foreach($columns as $columnId => $column)
<x-flowforge::column
:columnId="$columnId"
:column="$column"
:config="$config"
wire:key="column-{{ $columnId }}"
/>
@endforeach
@if($swimlanes)
{{-- 2D Swimlane Grid Layout --}}
<x-flowforge::swimlane-board
:columns="$columns"
:swimlanes="$swimlanes"
:config="$config"
/>
@else
{{-- Flat Column Layout (original) --}}
<div class="flex-1 overflow-hidden h-full">
<div class="flex flex-row h-full overflow-x-auto overflow-y-hidden gap-5">
@foreach($columns as $columnId => $column)
<x-flowforge::column
:columnId="$columnId"
:column="$column"
:config="$config"
wire:key="column-{{ $columnId }}"
/>
@endforeach
</div>
</div>
</div>
@endif

<x-filament-actions::modals/>
</div>
69 changes: 48 additions & 21 deletions resources/views/livewire/card.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<div
@class([
'flowforge-card mb-3 bg-white dark:bg-gray-900 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-200 hover:shadow-md',
'flowforge-card mb-2 bg-white dark:bg-gray-900 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-200 hover:shadow-md max-w-[300px]',
'cursor-pointer' => $hasActions || $hasCardAction,
'cursor-pointer transition-all duration-100 ease-in-out hover:shadow-lg hover:border-gray-400 active:shadow-md' => $hasCardAction,
'cursor-grab hover:cursor-grabbing' => $hasPositionIdentifier,
Expand All @@ -23,34 +23,61 @@
@endif
data-position="{{ $record['position'] ?? '' }}"
>
<div class="flowforge-card-content">
<div class="flex items-start justify-between mb-2">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white p-3"
@if($hasCardAction && $cardAction)
wire:click="mountAction('{{ $cardAction }}', [], @js(['recordKey' => $record['id']]))"
style="cursor: pointer;"
<div class="flowforge-card-content px-3 pt-2 pb-2"
@if($hasCardAction && $cardAction)
wire:click="mountAction('{{ $cardAction }}', [], @js(['recordKey' => $record['id']]))"
style="cursor: pointer;"
@endif
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
{{-- Optional header (e.g. ticket number) --}}
@if(filled($record['headerSchema'] ?? null))
<div class="flowforge-card-schema">
{{ $record['headerSchema'] }}
</div>
@endif
>
{{ $record['title'] }}
</h4>

{{-- Card title --}}
<h4 class="text-xs font-semibold text-gray-900 dark:text-white leading-snug">
{{ $record['title'] }}
</h4>
</div>

@if($hasActions)
<div class="m-3">
<div class="flex-shrink-0 -mt-0.5 -mr-1">
<x-filament-actions::group :actions="$processedRecordActions"/>
</div>
@endif
</div>

<div class="px-3 pb-3"
@if($hasCardAction && $cardAction)
wire:click="mountAction('{{ $cardAction }}', [], @js(['recordKey' => $record['id']]))"
style="cursor: pointer;"
@endif
>
{{-- Render card schema with compact spacing --}}
@if(filled($record['schema']))
{{-- Card schema (body fields) --}}
@if(filled($record['schema']))
<div class="flowforge-card-schema">
{{ $record['schema'] }}
@endif
</div>
</div>
@endif
</div>

{{-- Optional card footer --}}
@if(filled($record['footerSchema'] ?? null))
<div class="flowforge-card-footer border-t border-gray-200 dark:border-gray-700 px-3 py-1.5">
<div class="flowforge-card-schema">
{{ $record['footerSchema'] }}
</div>
</div>
@endif
</div>

@once
<style>
/* Collapse Filament's default gap-6 between schema entries inside cards */
.flowforge-card-schema .fi-sc.fi-sc-has-gap {
gap: 0;
}
/* Remove internal gap within each entry wrapper */
.flowforge-card-schema .fi-in-entry {
gap: 0;
}
</style>
@endonce
146 changes: 146 additions & 0 deletions resources/views/livewire/swimlane-board.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
@php use Relaticle\Flowforge\Support\ColorResolver; @endphp
@props(['columns', 'swimlanes', 'config'])

@php
$columnIds = array_keys($columns);
$columnCount = count($columnIds);
$hasPositionIdentifier = $this->getBoard()->getPositionIdentifierAttribute() !== null;
@endphp

<div
class="flowforge-swimlane-board overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm"
x-data="{
collapsed: {},
init() {
try {
const stored = localStorage.getItem('flowforge:swimlanes:' + window.location.pathname);
if (stored) this.collapsed = JSON.parse(stored);
} catch(e) { this.collapsed = {}; }
},
toggle(id) {
this.collapsed[id] = !(this.collapsed[id] || false);
try { localStorage.setItem('flowforge:swimlanes:' + window.location.pathname, JSON.stringify(this.collapsed)); } catch(e) {}
},
isCollapsed(id) {
return this.collapsed[id] || false;
}
}"
>
<table class="border-collapse w-full" style="min-width: {{ 180 + ($columnCount * 300) }}px;">
{{-- Sticky Column Header Row --}}
<thead>
<tr>
{{-- Empty corner cell --}}
<th class="sticky top-0 left-0 z-30 bg-white dark:bg-gray-900 border-b border-r border-gray-200 dark:border-gray-700 p-2" style="width: 180px; min-width: 180px;"></th>

@foreach($columns as $columnId => $column)
@php
$resolvedColor = ColorResolver::resolve($column['color']);
$isSemantic = ColorResolver::isSemantic($resolvedColor);
@endphp
<th
class="sticky top-0 z-20 bg-white dark:bg-gray-900 border-b border-r border-gray-200 dark:border-gray-700 p-3 text-left font-normal"
style="min-width: 300px;"
wire:key="header-{{ $columnId }}"
>
<div class="flex items-center">
@if ($column['icon'] ?? null)
<x-filament::icon :icon="$column['icon']" class="h-4 w-4 text-gray-500 dark:text-gray-400 me-2" />
@endif
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">
{{ $column['label'] }}
</h3>
</div>
</th>
@endforeach
</tr>
</thead>

<tbody>
@foreach($swimlanes as $swimlaneId => $swimlane)
@php
$resolvedLaneColor = ColorResolver::resolve($swimlane['color']);
$isLaneSemantic = ColorResolver::isSemantic($resolvedLaneColor);
$laneColorShades = $isLaneSemantic ? null : $resolvedLaneColor;
@endphp

{{-- Swimlane Row --}}
<tr wire:key="lane-row-{{ $swimlaneId }}">
{{-- Swimlane Label Cell (row header) --}}
<td
class="sticky left-0 z-10 bg-white dark:bg-gray-900 border-b border-r border-gray-200 dark:border-gray-700 p-3"
style="width: 180px; min-width: 180px; vertical-align: top;"
wire:key="lane-label-{{ $swimlaneId }}"
>
<button
type="button"
class="flex items-center gap-2 w-full text-left group"
x-on:click="toggle('{{ $swimlaneId }}')"
>
{{-- Collapse/expand chevron --}}
<svg
class="w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 flex-shrink-0"
:class="{ '-rotate-90': isCollapsed('{{ $swimlaneId }}') }"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
>
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>

<span class="text-sm font-semibold text-gray-700 dark:text-gray-200 truncate">
{{ $swimlane['label'] }}
</span>

{{-- Count badge --}}
@if($isLaneSemantic)
<x-filament::badge tag="div" :color="$resolvedLaneColor" size="sm">
{{ $swimlane['total'] }}
</x-filament::badge>
@elseif($laneColorShades)
<div
@style([
Filament\Support\get_color_css_variables($resolvedLaneColor, shades: [50, 300, 600, 700])
])
@class([
'items-center border px-1.5 py-0.5 rounded-md text-xs font-semibold',
'bg-custom-50 dark:bg-custom-600/20',
'text-custom-700 dark:text-custom-300',
'border-custom-700/30 dark:border-custom-300/30',
])
>
{{ $swimlane['total'] }}
</div>
@else
<div class="items-center border px-1.5 py-0.5 rounded-md text-xs font-semibold bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600">
{{ $swimlane['total'] }}
</div>
@endif
</button>
</td>

{{-- Swimlane Cells --}}
@foreach($columnIds as $columnId)
@php
$cell = $swimlane['cells'][$columnId] ?? ['items' => [], 'total' => 0];
$cellKey = $columnId . '|' . $swimlaneId;
@endphp
<td
class="border-b border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-0"
style="min-width: 300px; vertical-align: top;"
wire:key="cell-{{ $cellKey }}"
>
<div x-show="!isCollapsed('{{ $swimlaneId }}')">
<x-flowforge::swimlane-cell
:columnId="$columnId"
:swimlaneId="$swimlaneId"
:cell="$cell"
:config="$config"
:hasPositionIdentifier="$hasPositionIdentifier"
/>
</div>
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
Loading