Skip to content

Add swimlane support for 2D kanban boards (columns × rows) - #81

Open
optmsp wants to merge 10 commits into
relaticle:3.xfrom
giantrocketship:3.x
Open

optmsp wants to merge 10 commits into
relaticle:3.xfrom
giantrocketship:3.x

Conversation

@optmsp

@optmsp optmsp commented Feb 1, 2026

Copy link
Copy Markdown

Introduce optional swimlane grouping that creates a grid layout with columns as the primary axis and swimlanes as secondary row grouping. Boards without swimlanes configured continue to work identically.

New classes:

  • Swimlane: ViewComponent mirroring Column (name, label, color, icon)
  • HasBoardSwimlanes: Trait for swimlane configuration on Board

Data layer:

  • getBoardRecordsForCell(): query records by column × swimlane
  • getBatchedSwimlaneRecordCounts(): GROUP BY column, swimlane counts
  • Board::getViewData() branches into flat vs 2D swimlane structure

Views:

  • swimlane-board.blade.php: HTML table with sticky headers/labels, inline x-data for collapse state (avoids x-load timing issues)
  • swimlane-cell.blade.php: per-cell card list with independent scroll
  • index.blade.php: conditional flat vs swimlane rendering

Features:

  • Collapsible swimlane rows with localStorage persistence
  • Per-cell pagination via composite "columnId|swimlaneId" keys
  • Horizontal-only drag via per-swimlane sortable groups
  • Uncategorized swimlane for records with no matching lane value
  • Filament theme-compatible styling (bg-white/dark:bg-gray-900)

optmsp and others added 4 commits January 31, 2026 22:39
Introduce optional swimlane grouping that creates a grid layout with
columns as the primary axis and swimlanes as secondary row grouping.
Boards without swimlanes configured continue to work identically.

New classes:
- Swimlane: ViewComponent mirroring Column (name, label, color, icon)
- HasBoardSwimlanes: Trait for swimlane configuration on Board

Data layer:
- getBoardRecordsForCell(): query records by column × swimlane
- getBatchedSwimlaneRecordCounts(): GROUP BY column, swimlane counts
- Board::getViewData() branches into flat vs 2D swimlane structure

Views:
- swimlane-board.blade.php: HTML table with sticky headers/labels,
  inline x-data for collapse state (avoids x-load timing issues)
- swimlane-cell.blade.php: per-cell card list with independent scroll
- index.blade.php: conditional flat vs swimlane rendering

Features:
- Collapsible swimlane rows with localStorage persistence
- Per-cell pagination via composite "columnId|swimlaneId" keys
- Horizontal-only drag via per-swimlane sortable groups
- Uncategorized swimlane for records with no matching lane value
- Filament theme-compatible styling (bg-white/dark:bg-gray-900)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove max-height and overflow-auto from swimlane-board container,
  keep only overflow-x-auto for wide boards
- Remove max-height and overflow-y-auto from swimlane cells so cards
  expand to natural height
- Remove overflow-hidden wrapper from swimlane board in index view
  so content is not clipped to viewport height

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Use inline vertical-align: top on td elements instead of Tailwind
  align-top class (not compiled from vendor blade by JIT)
- Use inline padding on swimlane cells instead of Tailwind p-2
- Remove dashed placeholder boxes from empty cells
- Remove min-height constraint from cells

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Reduce card margin (mb-3 → mb-2), title size (text-sm → text-xs),
  and padding (p-3 → px-3 pt-2 / px-3 pb-2)
- Remove bottom margin from header row
- Wrap schema output in .flowforge-card-schema div
- Add scoped CSS to collapse Filament's default gap-6 between schema
  entries and gap-y-2 within entry wrappers to zero

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@optmsp
optmsp marked this pull request as draft February 1, 2026 05:45
Columns expand to fill available space (w-full table, min-width 300px)
while cards are capped at max-w-[300px] for consistent sizing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@optmsp

optmsp commented Feb 1, 2026

Copy link
Copy Markdown
Author

I need to remove my customization of cards, etc., and make that user-defined before this PR is good.

New cardHeaderSchema() and cardFooterSchema() methods mirror the
existing cardSchema() pattern. Header renders above the title,
footer renders below body with a border separator. Both are optional
and only render when configured.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@ManukMinasyan ManukMinasyan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a well-architected feature with excellent pattern consistency (Swimlane mirrors Column). The backward compatibility is solid -- boards without swimlanes work identically. However, several issues need attention before merge:

Critical Issues

1. Zero Test Coverage

No tests added for +838 lines of new functionality. Need tests for:

  • getBoardRecordsForCell() query correctness
  • getBatchedSwimlaneRecordCounts() GROUP BY logic
  • Uncategorized record handling
  • Cell pagination ("load more" per cell)
  • Board rendering with swimlanes enabled

2. Wrong Target Branch

This targets 3.x (maintenance-only) but should target 4.x (active development with Filament 5). Merging to 3.x creates version confusion.

3. Cross-Swimlane Drag Not Supported

moveCard() only receives column ID, not swimlane ID. Cards cannot be moved between swimlanes. If swimlanes are read-only grouping, this should be documented. If mutable, moveCard() needs a swimlane parameter.

Performance Concerns

4. N+1 Query Risk

getBoardRecordsForCell() is called per column x swimlane intersection. With 5 columns x 5 swimlanes = 25 separate queries on page load. Consider batching or eager loading.

5. Unoptimized Lookups

in_array($laneId, $definedSwimlaneIds, true) inside nested loops -- use array_flip() for O(1) lookup.

Moderate Issues

6. localStorage Key Collision

window.location.pathname means two different boards on the same URL path share collapse state. Should include a board-specific identifier.

7. Missing Accessibility

Collapse buttons lack aria-expanded and aria-controls attributes. No keyboard navigation for collapse/expand.

8. Trait Inconsistency

Swimlane lacks translateLabel() that Column has. Icon-related traits (HasIconColor, HasIconPosition) present in Column are missing from Swimlane.

Minor Issues

  • Hardcoded 300px column width in min-width calc
  • getViewDataWithSwimlanes() is 60+ lines -- could extract smaller methods
  • getBoardRecordsForCell() duplicates query-building logic from getBoardRecords()

Overall the architecture is strong, but this needs tests, performance validation, and retargeting to 4.x before it can be merged.

optmsp and others added 4 commits September 14, 2026 07:35
… cell

getViewDataWithSwimlanes() batches the cell counts and then throws that work
away: the loop below reads $cellCount and calls getBoardRecordsForCell() on the
very next line regardless, once per column x swimlane.

On a board with 39 columns and 4 swimlanes that is 156 queries to render 31
cards. 150 of them ask for the contents of a cell the loop has already been
told is empty.

The cost per query is not uniform either. A board query built over a derived
table -- the usual shape when the grouping values are computed rather than
stored -- exposes its column and swimlane keys as aliases, so the per-cell
WHERE cannot use an index and every cell re-runs the full projection.

One query now fetches the filtered set and groups it by column|swimlane in
memory. The per-cell limit, including the load-more override in
$columnCardLimits, is applied to the group rather than in SQL, so counts and
pagination behave as before. Ordering mirrors what getBoardRecordsForCell()
applied so the grouped result keeps the same order.

getBoardRecordsForCell() is untouched and still serves the load-more path.

Measured on a 39x4 board rendering 31 cards, five runs each, medians:

  queries      210 -> 50
  database     653ms -> 22ms
  server time  1984ms -> 1040ms

Output verified byte-identical after normalising the per-request tokens
Livewire stamps on every response, in both the all-columns and filtered states.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Vj99aoZp7rQPd7HFzG2DJ
A board whose columns, query or swimlanes derive from $tableFilters or
$tableSearch rendered one round trip stale. Toggling a filter that adds or
removes columns returned the previous column set, and it stayed wrong until
something unrelated triggered another render -- a poll, or any other action.

bootedInteractsWithBoard() builds the board, and Livewire runs booted() before
it applies the incoming property update. So board() reads the filter state from
the request before the one being handled. Observed by intercepting the toggle's
own livewire/update response: switching a filter from 3 columns to 39 returned
a payload containing 3 column headers.

booted() still builds it, because cacheBoardActions() has to run before Filament
processes the request. The new hook rebuilds once the new state is in place.

Scoped to table state on purpose. An unguarded updated hook also fires for
$columnCardLimits, which load-more-on-scroll writes to, and would throw away the
pagination it had just set.

Verified on a board that swings between 3 and 39 columns: four consecutive
toggles, both directions, each returning the correct column set in its own
response. Request cost unchanged at 50-51 queries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Vj99aoZp7rQPd7HFzG2DJ
# Conflicts:
#	resources/dist/flowforge.js
#	resources/js/flowforge.js
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants