Skip to content

fix: render cards as native links when the card action has a url - #166

Merged
ManukMinasyan merged 4 commits into
4.xfrom
fix/card-action-url
Aug 19, 2026
Merged

fix: render cards as native links when the card action has a url#166
ManukMinasyan merged 4 commits into
4.xfrom
fix/card-action-url

Conversation

@ManukMinasyan

@ManukMinasyan ManukMinasyan commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #164.

The bug

->cardActions([
    Action::make('view')->url(fn (Task $record) => TaskResource::getUrl('view', ['record' => $record])),
])
->cardAction('view')

Clicking a card does nothing at all. Not a modal, not navigation. ->url() is ignored.

Root cause

resources/views/livewire/card.blade.php hardcoded the Livewire handler on both click targets (the title <h4> and the body <div>):

wire:click="mountAction('{{ $cardAction }}', [], @js(['recordKey' => $record['id']]))"

There was no url branch anywhere in the card render path. Board::getCardAction() returns only a name, so the resolved Action instance was never consulted and its url could never be read.

The contrast is visible on a single card in a single response. Same action, two renderings:

Where Markup
card title (cardAction) <h4 wire:click="mountAction('view', [], {recordKey: …})">
actions dropdown (cardActions) <a href="https://…/tasks/01kxm3…">

The dropdown is correct because it goes through Filament's own action renderer. Mounting an action that has no modal and no callback is a no-op, so the click silently did nothing: mountedActions came back empty, the url never changed, and nothing was logged.

The fix

Board::resolveCardAction() finds the configured card action among the record actions already processed for that card (flattening ActionGroups). Taking them from getBoardRecordActions() rather than rebuilding matters: those clones carry the record binding, which is what makes ->url(fn ($record) => ...) resolvable.

The blade then renders the title and body as anchors when the action has a url, built with Filament\Support\generate_href_html() and hasNestedClickEventHandler: true, the same helper and flag Filament's table rows use for recordUrl. SPA mode, prefetching, modifier-key handling and ->openUrlInNewTab() therefore behave exactly as they do elsewhere in the panel.

Following the framework's rule

The url on the action decides, with no check of its modal state. That is Filament's own rule, from ListRecords::table():

  • recordAction() iterates view/edit and skips any action whose getUrl() is filled
  • recordUrl() iterates the same actions and skips any whose getUrl() is falsy

Neither closure consults hasModal() or shouldOpenModal().

This PR originally added hasUrl() and shouldOpenModal() guards so that an action declaring both a url and a confirmation would keep showing the confirmation. That was wrong, and the last commit reverts it. Verified against a real Filament table in a Filament 5.4 app: with core's own ListRecords defaulting logic and an action carrying ->url()->requiresConfirmation(), the table row renders <a href> and clicking it navigates with no confirmation. Keeping a stricter rule for boards would have meant one action behaving differently in a table row and on a card, which is worse than the config being self-contradictory in the first place.

So: the url wins in a board card, a table row, and the actions dropdown alike. An action should declare a url or a modal, not both. Only ->postToUrl() actions stay on the Livewire click handler, because a POST needs a form rather than an anchor.

Test plan

Nine tests in tests/Feature/CardActionUrlTest.php, across six BoardPage fixtures (url, new-tab url, modal, default-action-url page, url + confirmation, url + custom modal heading):

  • card title renders href for a url card action
  • no mountAction('view' handler is emitted anywhere for a url card action
  • ->openUrlInNewTab() produces target="_blank"
  • a card action without a url still emits mountAction('edit' and no href
  • a card action inheriting a default action url renders as a link
  • a url card action that also declares a confirmation renders as a link
  • a url card action that also declares a modal heading renders as a link
  • resolveCardAction() finds the action, and returns null when none is configured

Assertions target the card title specifically, not the whole page: the actions dropdown renders its own anchor for the same action and would satisfy a page-wide assertion even with the bug present.

Also verified locally:

  • vendor/bin/pest: 156 passed
  • vendor/bin/pint: passed
  • vendor/bin/phpstan analyse: 7 errors, in the same seven places as unmodified 4.x (pre-existing, none in the changed code)
  • Browser walk in a real Filament 5.4 app, covering the shipped board configuration and every new path: a plain url card action renders <a href> and navigates (previously the url never changed); openUrlInNewTab yields target="_blank"; a card action without a url still opens its modal; a bare ViewAction still opens its modal; browser Back after navigating restores the board. A screenshot of the linked board is indistinguishable from the board before the change, so the anchors introduce no visual difference.

Docs: added the url case to the cardAction() section of the API reference, including the "a url decides" precedence and its two consequences.

Worth squashing on merge: the four commits include one wrong turn and its revert.

The card blade hardcoded wire:click="mountAction(...)" on both click targets, so a
card action configured with ->url() never navigated. It mounted an action with no
modal and no callback instead, leaving the click a no-op: no navigation, no modal,
nothing logged. The same action rendered correctly as an anchor in the card's
actions dropdown, because that path goes through Filament's own renderer.

Resolve the configured card action from the already-processed record actions, so
per-record closures such as ->url(fn ($record) => ...) evaluate against the right
record, and render the card title and body as anchors when it has a url. Uses
Filament\Support\generate_href_html(), matching how table rows build record urls,
so SPA mode, prefetching, modifier keys and ->openUrlInNewTab() all behave the same
as everywhere else in the panel.

Actions with a modal, and actions posting to a url, keep the existing click handler.

Fixes #164
Copilot AI lite review requested due to automatic review settings August 19, 2026 10:01

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

getUrl() falls back to the livewire component's getDefaultActionUrl(), which
Filament's resource pages implement for modal-less Create/Edit/View actions.
BoardResourcePage extends that class, so honouring the fallback would have turned
existing boards into links without the developer asking for it. Gate on hasUrl()
so only an explicit ->url() opts a card into link rendering.
hasModal() only reports an explicit ->modal() call, so ->requiresConfirmation() and
a custom modal heading, description or content left getUrl() returning a url. A card
action carrying both then rendered as a link and navigated on click, skipping a
confirmation that v4.0.14 showed. A whole card is an easy target to click by
accident, so the modal now wins over the url.

Filament's own dropdown renders such an action as a plain anchor. The card is
deliberately more conservative than that, because a menu item is a considered click
and a card is not.
The previous guard added hasUrl() and shouldOpenModal() checks the framework does
not make. Filament's ListRecords::table() builds recordUrl() from the first
view/edit action whose getUrl() is filled and has recordAction() skip those same
actions, without consulting modal state, so a url action that also declares a
confirmation already renders as a plain link in a table row. Verified against a
real table in a Filament 5.4 app.

Inventing a stricter rule for boards meant the same action behaved one way in a
table and another on a card. Follow the framework instead: the url decides, and
only postToUrl() actions stay on the click handler because a POST needs a form.
@ManukMinasyan
ManukMinasyan merged commit 424a446 into 4.x Aug 19, 2026
4 checks passed
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.

[Bug]: cardAction ignores ->url() and forces a modal instead of generating a native link

2 participants