diff --git a/packages/support/src/Concerns/CanBeCopied.php b/packages/support/src/Concerns/CanBeCopied.php index 02c8ae84e3e..04e03ca622a 100644 --- a/packages/support/src/Concerns/CanBeCopied.php +++ b/packages/support/src/Concerns/CanBeCopied.php @@ -44,30 +44,22 @@ public function copyMessageDuration(int | Closure | null $duration): static public function isCopyable(mixed $state): bool { - return (bool) $this->evaluate($this->isCopyable, [ - 'state' => $state, - ]); + return (bool) $this->evaluate($this->isCopyable, $this->getEvaluationsForStateItem($state)); } public function getCopyableState(mixed $state): ?string { - return $this->evaluate($this->copyableState, [ - 'state' => $state, - ]); + return $this->evaluate($this->copyableState, $this->getEvaluationsForStateItem($state)); } public function getCopyMessage(mixed $state): string { - return $this->evaluate($this->copyMessage, [ - 'state' => $state, - ]) ?? __('filament::components/copyable.messages.copied'); + return $this->evaluate($this->copyMessage, $this->getEvaluationsForStateItem($state)) ?? __('filament::components/copyable.messages.copied'); } public function getCopyMessageDuration(mixed $state): int { - return $this->evaluate($this->copyMessageDuration, [ - 'state' => $state, - ]) ?? 2000; + return $this->evaluate($this->copyMessageDuration, $this->getEvaluationsForStateItem($state)) ?? 2000; } public function hasCopyable(): bool diff --git a/packages/support/src/Concerns/EvaluatesClosures.php b/packages/support/src/Concerns/EvaluatesClosures.php index 0c5d0b2463a..753b42b02f9 100644 --- a/packages/support/src/Concerns/EvaluatesClosures.php +++ b/packages/support/src/Concerns/EvaluatesClosures.php @@ -133,6 +133,18 @@ protected function resolveDefaultClosureDependencyForEvaluationByType(string $pa return []; } + /** + * @return array + */ + protected function getEvaluationsForStateItem(mixed $state): array + { + if (method_exists($this, 'getNamedInjectionsForStateItem')) { + return $this->getNamedInjectionsForStateItem($state); + } + + return ['state' => $state]; + } + protected function getTypedReflectionParameterClassName(ReflectionParameter $parameter): ?string { $type = $parameter->getType(); diff --git a/packages/support/src/Concerns/HasCellState.php b/packages/support/src/Concerns/HasCellState.php index 9f864066882..3186813cf10 100644 --- a/packages/support/src/Concerns/HasCellState.php +++ b/packages/support/src/Concerns/HasCellState.php @@ -34,6 +34,16 @@ trait HasCellState */ protected array $cachedState = []; + /** + * @var array> + */ + protected array $cachedRelationshipRecords = []; + + /** + * @var array|null + */ + protected ?array $lastResolvedRelationshipRecords = null; + protected ?bool $hasMultipleRelationshipCache = null; protected ?Relation $relationshipCache = null; @@ -123,30 +133,13 @@ public function getStateFromRecord(): mixed $relationship = $this->getRelationship($record); if ($relationship) { - $relationshipAttribute = $this->getFullAttributeName($record); - - $state = collect($this->getRelationshipResults($record)) - ->reduce( - function (Collection $carry, Model $record) use ($relationshipAttribute): Collection { - if ( - ($record instanceof HasRichContent) && - $record->hasRichContentAttribute($relationshipAttribute) - ) { - $state = $record->getRichContentAttribute($relationshipAttribute); - } else { - $state = data_get($record, $relationshipAttribute); - } - - if (blank($state)) { - return $carry; - } - - return $carry->push($state); - }, - initial: collect(), - ) - ->when($this->isDistinctList(), fn (Collection $state) => $state->unique()) - ->values(); + $pairs = $this->resolveRelationshipStatePairs($record); + + $this->lastResolvedRelationshipRecords = $pairs + ->pluck('record') + ->all(); + + $state = $pairs->pluck('state'); if (! $state->count()) { return null; @@ -160,6 +153,8 @@ function (Collection $carry, Model $record) use ($relationshipAttribute): Collec } } + $this->lastResolvedRelationshipRecords = null; + $name = $this->getName(); if ( @@ -177,6 +172,79 @@ function (Collection $carry, Model $record) use ($relationshipAttribute): Collec public function clearCachedState(): void { $this->cachedState = []; + $this->cachedRelationshipRecords = []; + } + + /** + * @return array + */ + public function getRelationshipRecords(): array + { + $this->getState(); + + $record = $this->getRecord(); + + if (! $record) { + return []; + } + + $recordKey = $this->resolveCachedStateRecordKey($record); + + if (blank($recordKey)) { + return $this->lastResolvedRelationshipRecords ?? []; + } + + return $this->cachedRelationshipRecords[$recordKey] ?? []; + } + + public function getRelationshipRecord(): ?Model + { + $records = $this->getRelationshipRecords(); + + if (count($records) !== 1) { + return null; + } + + return $records[0]; + } + + /** + * @return Collection + */ + protected function resolveRelationshipStatePairs(Model $record): Collection + { + $relationshipAttribute = $this->getFullAttributeName($record); + + return collect($this->getRelationshipResults($record)) + ->map(function (Model $relatedRecord) use ($relationshipAttribute): ?array { + if ( + ($relatedRecord instanceof HasRichContent) && + $relatedRecord->hasRichContentAttribute($relationshipAttribute) + ) { + $state = $relatedRecord->getRichContentAttribute($relationshipAttribute); + } else { + $state = data_get($relatedRecord, $relationshipAttribute); + } + + if (blank($state)) { + return null; + } + + return [ + 'state' => $state, + 'record' => $relatedRecord, + ]; + }) + ->filter() + ->when( + $this->isDistinctList(), + fn (Collection $pairs) => $pairs->unique( + fn (array $pair): string => is_scalar($pair['state']) + ? (string) $pair['state'] + : serialize($pair['state']), + ), + ) + ->values(); } public function separator(string | Closure | null $separator = ','): static @@ -579,15 +647,11 @@ protected function cacheState(Closure $state): mixed return null; } - if ($this instanceof Column) { - $recordKey = $this->getLivewire()->getTableRecordKey($record); - } elseif (is_array($record)) { /** @phpstan-ignore function.impossibleType */ - $recordKey = (string) ($record[ArrayRecord::getKeyName()] ?? null); /** @phpstan-ignore nullCoalesce.offset */ - } else { - $recordKey = (string) $record->getKey(); - } + $recordKey = $this->resolveCachedStateRecordKey($record); if (blank($recordKey)) { + $this->lastResolvedRelationshipRecords = null; + return $state(); } @@ -595,7 +659,28 @@ protected function cacheState(Closure $state): mixed return $this->cachedState[$recordKey]; } - return $this->cachedState[$recordKey] = $state(); + $this->lastResolvedRelationshipRecords = null; + + $computedState = $state(); + + if ($this->lastResolvedRelationshipRecords !== null) { + $this->cachedRelationshipRecords[$recordKey] = $this->lastResolvedRelationshipRecords; + } + + return $this->cachedState[$recordKey] = $computedState; + } + + protected function resolveCachedStateRecordKey(mixed $record): ?string + { + if ($this instanceof Column) { + return $this->getLivewire()->getTableRecordKey($record); + } + + if (is_array($record)) { /** @phpstan-ignore function.impossibleType */ + return (string) ($record[ArrayRecord::getKeyName()] ?? null); /** @phpstan-ignore nullCoalesce.offset */ + } + + return (string) $record->getKey(); } public function getGetStateUsingCallback(): mixed diff --git a/packages/support/src/Concerns/HasFontFamily.php b/packages/support/src/Concerns/HasFontFamily.php index 96a779bdc8e..97ace88bbff 100644 --- a/packages/support/src/Concerns/HasFontFamily.php +++ b/packages/support/src/Concerns/HasFontFamily.php @@ -18,9 +18,7 @@ public function fontFamily(FontFamily | string | Closure | null $family): static public function getFontFamily(mixed $state = null): FontFamily | string | null { - $family = $this->evaluate($this->fontFamily, [ - 'state' => $state, - ]); + $family = $this->evaluate($this->fontFamily, $this->getEvaluationsForStateItem($state)); if (is_string($family)) { $family = FontFamily::tryFrom($family) ?? $family; diff --git a/packages/support/src/Concerns/HasWeight.php b/packages/support/src/Concerns/HasWeight.php index 845ba7fbac8..0bbf4ac0400 100644 --- a/packages/support/src/Concerns/HasWeight.php +++ b/packages/support/src/Concerns/HasWeight.php @@ -18,9 +18,7 @@ public function weight(FontWeight | string | Closure | null $weight): static public function getWeight(mixed $state = null): FontWeight | string | null { - $weight = $this->evaluate($this->weight, [ - 'state' => $state, - ]); + $weight = $this->evaluate($this->weight, $this->getEvaluationsForStateItem($state)); if (! is_string($weight)) { return $weight; diff --git a/packages/tables/src/Columns/Column.php b/packages/tables/src/Columns/Column.php index a197ddd5134..125dba26194 100644 --- a/packages/tables/src/Columns/Column.php +++ b/packages/tables/src/Columns/Column.php @@ -47,6 +47,7 @@ class Column extends ViewComponent use Concerns\HasLabel; use Concerns\HasName; use Concerns\HasRecord; + use Concerns\HasRelationshipRecords; use Concerns\HasRowLoopObject; use Concerns\InteractsWithTableQuery; use HasAlignment; @@ -102,6 +103,7 @@ protected function resolveDefaultClosureDependencyForEvaluationByName(string $pa return match ($parameterName) { 'livewire' => [$this->getLivewire()], 'record' => [$this->getRecord()], + 'relationshipRecord' => [$this->getRelationshipRecord()], 'rowLoop' => [$this->getRowLoop()], 'state' => [$this->getState()], 'table' => [$this->getTable()], diff --git a/packages/tables/src/Columns/Concerns/CanFormatState.php b/packages/tables/src/Columns/Concerns/CanFormatState.php index 4d93e1e1ebb..f46f51e888c 100644 --- a/packages/tables/src/Columns/Concerns/CanFormatState.php +++ b/packages/tables/src/Columns/Concerns/CanFormatState.php @@ -11,6 +11,7 @@ use Filament\Support\Facades\FilamentTimezone; use Filament\Tables\Columns\TextColumn; use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; use Illuminate\Support\HtmlString; use Illuminate\Support\Number; @@ -373,7 +374,7 @@ public function formatStateUsing(?Closure $callback): static return $this; } - public function formatState(mixed $state): mixed + public function formatState(mixed $state, ?Model $relationshipRecord = null): mixed { if (! $this->hasStateFormatting()) { if ($state instanceof LabelInterface) { @@ -385,9 +386,7 @@ public function formatState(mixed $state): mixed $isHtml = $this->isHtml(); - $state = $this->evaluate($this->formatStateUsing ?? $state, [ - 'state' => $state, - ]); + $state = $this->evaluateForStateItem($this->formatStateUsing ?? $state, $state, $relationshipRecord); if (is_array($state)) { $state = json_encode($state); diff --git a/packages/tables/src/Columns/Concerns/CanOpenUrl.php b/packages/tables/src/Columns/Concerns/CanOpenUrl.php index 59780714733..a0f211488be 100644 --- a/packages/tables/src/Columns/Concerns/CanOpenUrl.php +++ b/packages/tables/src/Columns/Concerns/CanOpenUrl.php @@ -3,6 +3,7 @@ namespace Filament\Tables\Columns\Concerns; use Closure; +use Illuminate\Database\Eloquent\Model; trait CanOpenUrl { @@ -32,13 +33,11 @@ public function url(string | Closure | null $url, bool | Closure | null $shouldO return $this; } - public function getUrl(mixed $state = null): ?string + public function getUrl(mixed $state = null, ?Model $relationshipRecord = null): ?string { - if (func_num_args() === 1) { + if (func_num_args() >= 1) { return $this->hasStateBasedUrls() - ? $this->evaluate($this->url, [ - 'state' => $state, - ]) + ? $this->evaluateForStateItem($this->url, $state, $relationshipRecord) : null; } @@ -51,7 +50,8 @@ public function getUrl(mixed $state = null): ?string public function hasStateBasedUrls(): bool { - return $this->evaluationValueIsFunctionAndHasParameter($this->url, parameterName: 'state'); + return $this->evaluationValueIsFunctionAndHasParameter($this->url, parameterName: 'state') + || $this->evaluationValueIsFunctionAndHasParameter($this->url, parameterName: 'relationshipRecord'); } public function shouldOpenUrlInNewTab(): bool diff --git a/packages/tables/src/Columns/Concerns/HasColor.php b/packages/tables/src/Columns/Concerns/HasColor.php index 7f15aa4d54a..9e630bcacc7 100644 --- a/packages/tables/src/Columns/Concerns/HasColor.php +++ b/packages/tables/src/Columns/Concerns/HasColor.php @@ -5,6 +5,7 @@ use Closure; use Filament\Support\Contracts\HasColor as ColorInterface; use Filament\Tables\Columns\Column; +use Illuminate\Database\Eloquent\Model; trait HasColor { @@ -52,11 +53,9 @@ public function colors(array | Closure $colors): static /** * @return string | array | null */ - public function getColor(mixed $state): string | array | null + public function getColor(mixed $state, ?Model $relationshipRecord = null): string | array | null { - $color = $this->evaluate($this->color, [ - 'state' => $state, - ]); + $color = $this->evaluateForStateItem($this->color, $state, $relationshipRecord); if ($color === false) { return null; diff --git a/packages/tables/src/Columns/Concerns/HasIcon.php b/packages/tables/src/Columns/Concerns/HasIcon.php index 9df21c27b39..0c45d7b7fa9 100644 --- a/packages/tables/src/Columns/Concerns/HasIcon.php +++ b/packages/tables/src/Columns/Concerns/HasIcon.php @@ -8,6 +8,7 @@ use Filament\Support\Enums\IconPosition; use Filament\Tables\Columns\Column; use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Database\Eloquent\Model; trait HasIcon { @@ -55,11 +56,9 @@ public function iconPosition(IconPosition | string | Closure | null $iconPositio return $this; } - public function getIcon(mixed $state): string | BackedEnum | Htmlable | null + public function getIcon(mixed $state, ?Model $relationshipRecord = null): string | BackedEnum | Htmlable | null { - $icon = $this->evaluate($this->icon, [ - 'state' => $state, - ]); + $icon = $this->evaluateForStateItem($this->icon, $state, $relationshipRecord); if ($icon === false) { return null; diff --git a/packages/tables/src/Columns/Concerns/HasIconColor.php b/packages/tables/src/Columns/Concerns/HasIconColor.php index 884b1959384..52016985eb6 100644 --- a/packages/tables/src/Columns/Concerns/HasIconColor.php +++ b/packages/tables/src/Columns/Concerns/HasIconColor.php @@ -3,6 +3,7 @@ namespace Filament\Tables\Columns\Concerns; use Closure; +use Illuminate\Database\Eloquent\Model; trait HasIconColor { @@ -24,10 +25,8 @@ public function iconColor(string | array | Closure | null $color): static /** * @return string | array | null */ - public function getIconColor(mixed $state): string | array | null + public function getIconColor(mixed $state, ?Model $relationshipRecord = null): string | array | null { - return $this->evaluate($this->iconColor, [ - 'state' => $state, - ]); + return $this->evaluateForStateItem($this->iconColor, $state, $relationshipRecord); } } diff --git a/packages/tables/src/Columns/Concerns/HasRelationshipRecords.php b/packages/tables/src/Columns/Concerns/HasRelationshipRecords.php new file mode 100644 index 00000000000..32922a97dc7 --- /dev/null +++ b/packages/tables/src/Columns/Concerns/HasRelationshipRecords.php @@ -0,0 +1,66 @@ + + */ + public function getNamedInjectionsForStateItem(mixed $state, ?Model $relationshipRecord = null): array + { + $injections = [ + 'state' => $state, + ]; + + $relationshipRecord ??= $this->evaluatingRelationshipRecord; + + if ($relationshipRecord !== null) { + $injections['relationshipRecord'] = $relationshipRecord; + } + + return $injections; + } + + protected function evaluateForStateItem(mixed $value, mixed $state, ?Model $relationshipRecord = null): mixed + { + return $this->evaluate($value, $this->getNamedInjectionsForStateItem($state, $relationshipRecord)); + } + + /** + * @param array $state + * @return array{state: array, relationshipRecords: array} + */ + protected function sliceStateWithRelationshipRecords(array $state, ?int $limit = null, bool $shouldSlice = true): array + { + $relationshipRecords = $this->getRelationshipRecords(); + + if ($limit !== null && $shouldSlice && (count($state) > $limit)) { + $state = array_slice($state, 0, $limit); + $relationshipRecords = array_slice($relationshipRecords, 0, $limit); + } + + return [ + 'state' => $state, + 'relationshipRecords' => $relationshipRecords, + ]; + } + + protected function withEvaluatingRelationshipRecord(?Model $relationshipRecord, Closure $callback): mixed + { + $previous = $this->evaluatingRelationshipRecord; + + $this->evaluatingRelationshipRecord = $relationshipRecord; + + try { + return $callback(); + } finally { + $this->evaluatingRelationshipRecord = $previous; + } + } +} diff --git a/packages/tables/src/Columns/Concerns/HasTooltip.php b/packages/tables/src/Columns/Concerns/HasTooltip.php index f2735a91f60..410b0c540fe 100644 --- a/packages/tables/src/Columns/Concerns/HasTooltip.php +++ b/packages/tables/src/Columns/Concerns/HasTooltip.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Database\Eloquent\Model; trait HasTooltip { @@ -20,11 +21,9 @@ public function tooltip(string | Htmlable | Closure | null $tooltip): static return $this; } - public function getTooltip(mixed $state = null): string | Htmlable | null + public function getTooltip(mixed $state = null, ?Model $relationshipRecord = null): string | Htmlable | null { - return $this->evaluate($this->tooltip, [ - 'state' => $state, - ]); + return $this->evaluateForStateItem($this->tooltip, $state, $relationshipRecord); } public function headerTooltip(string | Htmlable | Closure | null $tooltip): static diff --git a/packages/tables/src/Columns/IconColumn.php b/packages/tables/src/Columns/IconColumn.php index 569b963ece8..30bcb9d04c4 100644 --- a/packages/tables/src/Columns/IconColumn.php +++ b/packages/tables/src/Columns/IconColumn.php @@ -148,11 +148,9 @@ public function size(IconSize | string | Closure | null $size): static return $this; } - public function getSize(mixed $state): IconSize | string | null + public function getSize(mixed $state, ?Model $relationshipRecord = null): IconSize | string | null { - $size = $this->evaluate($this->size, [ - 'state' => $state, - ]); + $size = $this->evaluateForStateItem($this->size, $state, $relationshipRecord); if (blank($size)) { return null; @@ -169,9 +167,9 @@ public function getSize(mixed $state): IconSize | string | null return $size; } - public function getIcon(mixed $state): string | BackedEnum | Htmlable | null + public function getIcon(mixed $state, ?Model $relationshipRecord = null): string | BackedEnum | Htmlable | null { - if (filled($icon = $this->getBaseIcon($state))) { + if (filled($icon = $this->getBaseIcon($state, $relationshipRecord))) { return $icon; } @@ -189,9 +187,9 @@ public function getIcon(mixed $state): string | BackedEnum | Htmlable | null /** * @return string | array | null */ - public function getColor(mixed $state): string | array | null + public function getColor(mixed $state, ?Model $relationshipRecord = null): string | array | null { - if (filled($color = $this->getBaseColor($state))) { + if (filled($color = $this->getBaseColor($state, $relationshipRecord))) { return $color; } @@ -310,6 +308,8 @@ public function toEmbeddedHtml(): string $state = Arr::wrap($state); + $relationshipRecords = $this->getRelationshipRecords(); + $attributes = $attributes ->class([ 'fi-ta-icon-has-line-breaks' => $this->isListWithLineBreaks(), @@ -318,19 +318,19 @@ public function toEmbeddedHtml(): string $shouldOpenUrlInNewTab = $this->shouldOpenUrlInNewTab(); - $formatState = function (mixed $stateItem) use ($shouldOpenUrlInNewTab): string { - $icon = $this->getIcon($stateItem); + $formatState = function (mixed $stateItem, ?Model $relationshipRecord = null) use ($shouldOpenUrlInNewTab): string { + $icon = $this->getIcon($stateItem, $relationshipRecord); if (blank($icon)) { return ''; } - $color = $this->getColor($stateItem); - $size = $this->getSize($stateItem); + $color = $this->getColor($stateItem, $relationshipRecord); + $size = $this->getSize($stateItem, $relationshipRecord); $item = generate_icon_html($icon, attributes: (new FilamentComponentAttributeBag) ->merge([ - 'x-tooltip' => filled($tooltip = $this->getTooltip($stateItem)) + 'x-tooltip' => filled($tooltip = $this->getTooltip($stateItem, $relationshipRecord)) ? '{ content: ' . Js::from($tooltip) . ', theme: $store.theme, @@ -358,7 +358,7 @@ public function toEmbeddedHtml(): string $item .= '' . e(trim(strip_tags((string) $stateItemTextAlternative))) . ''; } - if (filled($url = $this->getUrl($stateItem))) { + if (filled($url = $this->getUrl($stateItem, $relationshipRecord))) { $item = 'toHtml() . '>' . $item . ''; } @@ -368,8 +368,8 @@ public function toEmbeddedHtml(): string ob_start(); ?>
toHtml() ?>> - - + $stateItem) { ?> +
diff --git a/packages/tables/src/Columns/ImageColumn.php b/packages/tables/src/Columns/ImageColumn.php index e1a27119736..f72e87ca95a 100644 --- a/packages/tables/src/Columns/ImageColumn.php +++ b/packages/tables/src/Columns/ImageColumn.php @@ -10,6 +10,7 @@ use Filament\Support\View\ComponentAttributeBag as FilamentComponentAttributeBag; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Arr; use Illuminate\Support\Collection; @@ -474,6 +475,8 @@ public function toEmbeddedHtml(): string } $state = Arr::wrap($state); + + $relationshipRecords = $this->getRelationshipRecords(); $stateCount = count($state); $limit = $this->getLimit() ?? $stateCount; @@ -483,7 +486,10 @@ public function toEmbeddedHtml(): string : 0; if ($stateOverLimitCount) { - $state = array_slice($state, 0, $limit); + [ + 'state' => $state, + 'relationshipRecords' => $relationshipRecords, + ] = $this->sliceStateWithRelationshipRecords($state, $limit); } $isCircular = $this->isCircular(); @@ -505,12 +511,12 @@ public function toEmbeddedHtml(): string $shouldOpenUrlInNewTab = $this->shouldOpenUrlInNewTab(); - $formatState = function (mixed $stateItem) use ($defaultImageUrl, $width, $height, $shouldOpenUrlInNewTab): string { + $formatState = function (mixed $stateItem, ?Model $relationshipRecord = null) use ($defaultImageUrl, $width, $height, $shouldOpenUrlInNewTab): string { $item = 'getExtraImgAttributeBag() ->merge([ 'alt' => e($this->getAlt($stateItem) ?? ''), 'src' => e(filled($stateItem) ? ($this->getImageUrl($stateItem) ?? $defaultImageUrl) : $defaultImageUrl), - 'x-tooltip' => filled($tooltip = $this->getTooltip($stateItem)) + 'x-tooltip' => filled($tooltip = $this->getTooltip($stateItem, $relationshipRecord)) ? '{ content: ' . Js::from($tooltip) . ', theme: $store.theme, @@ -525,7 +531,7 @@ public function toEmbeddedHtml(): string ->toHtml() . ' />'; - if (filled($url = $this->getUrl($stateItem))) { + if (filled($url = $this->getUrl($stateItem, $relationshipRecord))) { $item = 'toHtml() . '>' . $item . ''; } @@ -535,8 +541,8 @@ public function toEmbeddedHtml(): string ob_start(); ?>
toHtml() ?>> - - + $stateItem) { ?> + diff --git a/packages/tables/src/Columns/TextColumn.php b/packages/tables/src/Columns/TextColumn.php index c1b437336e3..17a14f8bbc5 100644 --- a/packages/tables/src/Columns/TextColumn.php +++ b/packages/tables/src/Columns/TextColumn.php @@ -23,6 +23,7 @@ use Filament\Tables\View\Components\Columns\TextColumnComponent\ItemComponent; use Filament\Tables\View\Components\Columns\TextColumnComponent\ItemComponent\IconComponent; use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Js; @@ -109,11 +110,9 @@ public function size(TextSize | string | Closure | null $size): static return $this; } - public function getSize(mixed $state): TextSize | string + public function getSize(mixed $state, ?Model $relationshipRecord = null): TextSize | string { - $size = $this->evaluate($this->size, [ - 'state' => $state, - ]); + $size = $this->evaluateForStateItem($this->size, $state, $relationshipRecord); if (blank($size)) { return TextSize::Small; @@ -324,24 +323,6 @@ public function toEmbeddedHtml(): string $shouldOpenUrlInNewTab = $this->shouldOpenUrlInNewTab(); - $formatState = function (mixed $stateItem, mixed $formattedState = null) use ($shouldOpenUrlInNewTab): string { - $url = $this->getUrl($stateItem); - - $item = ''; - - if (filled($url)) { - $item .= 'toHtml() . '>'; - } - - $item .= e($formattedState ?? $this->formatState($stateItem)); - - if (filled($url)) { - $item .= ''; - } - - return $item; - }; - /** @var array $state */ $state = Arr::wrap($state); @@ -349,6 +330,7 @@ public function toEmbeddedHtml(): string $listLimit = $this->getListLimit() ?? $stateCount; $stateOverListLimitCount = 0; + $relationshipRecords = $this->getRelationshipRecords(); if ($listLimit && ($stateCount > $listLimit)) { $stateOverListLimitCount = $stateCount - $listLimit; @@ -357,10 +339,31 @@ public function toEmbeddedHtml(): string (! $isListWithLineBreaks) || (! $isLimitedListExpandable) ) { - $state = array_slice($state, 0, $listLimit); + [ + 'state' => $state, + 'relationshipRecords' => $relationshipRecords, + ] = $this->sliceStateWithRelationshipRecords($state, $listLimit); } } + $formatState = function (mixed $stateItem, ?Model $relationshipRecord = null, mixed $formattedState = null) use ($shouldOpenUrlInNewTab): string { + $url = $this->getUrl($stateItem, $relationshipRecord); + + $item = ''; + + if (filled($url)) { + $item .= 'toHtml() . '>'; + } + + $item .= e($formattedState ?? $this->formatState($stateItem, $relationshipRecord)); + + if (filled($url)) { + $item .= ''; + } + + return $item; + }; + $isCollapsedList = false; if (($stateCount > 1) && (! $isListWithLineBreaks) && (! $isBadge)) { @@ -368,14 +371,16 @@ public function toEmbeddedHtml(): string implode( ', ', array_map( - fn (mixed $stateItem): string => $formatState($stateItem), + fn (mixed $stateItem, int $index): string => $formatState($stateItem, $relationshipRecords[$index] ?? null), $state, + array_keys($state), ), ), ]; $stateCount = 1; - $formatState = fn (mixed $stateItem, mixed $formattedState = null): string => $stateItem; + $relationshipRecords = []; + $formatState = fn (mixed $stateItem, ?Model $relationshipRecord = null, mixed $formattedState = null): string => $stateItem; $isCollapsedList = true; } @@ -389,84 +394,86 @@ public function toEmbeddedHtml(): string $iconPosition = $this->getIconPosition(); $isBulleted = $this->isBulleted(); - $getStateItem = function (mixed $stateItem, mixed $formattedState = null) use ($iconPosition, $isBadge, $lineClamp): array { - $color = $this->getColor($stateItem) ?? ($isBadge ? 'primary' : null); - $iconColor = $this->getIconColor($stateItem); + $getStateItem = function (mixed $stateItem, ?Model $relationshipRecord = null, mixed $formattedState = null) use ($iconPosition, $isBadge, $lineClamp): array { + return $this->withEvaluatingRelationshipRecord($relationshipRecord, function () use ($stateItem, $relationshipRecord, $formattedState, $iconPosition, $isBadge, $lineClamp): array { + $color = $this->getColor($stateItem, $relationshipRecord) ?? ($isBadge ? 'primary' : null); + $iconColor = $this->getIconColor($stateItem, $relationshipRecord); - $size = $this->getSize($stateItem); + $size = $this->getSize($stateItem, $relationshipRecord); - $iconHtml = generate_icon_html($this->getIcon($stateItem), attributes: (new FilamentComponentAttributeBag) - ->merge(['aria-hidden' => 'true'], escape: false) - ->color(IconComponent::class, $iconColor), size: match ($size) { - TextSize::Medium => IconSize::Medium, - TextSize::Large => IconSize::Large, - default => IconSize::Small, - })?->toHtml(); + $iconHtml = generate_icon_html($this->getIcon($stateItem, $relationshipRecord), attributes: (new FilamentComponentAttributeBag) + ->merge(['aria-hidden' => 'true'], escape: false) + ->color(IconComponent::class, $iconColor), size: match ($size) { + TextSize::Medium => IconSize::Medium, + TextSize::Large => IconSize::Large, + default => IconSize::Small, + })?->toHtml(); - $isCopyable = $this->isCopyable($stateItem); + $isCopyable = $this->isCopyable($stateItem); - if ($isCopyable) { - $copyableStateJs = Js::from($this->getCopyableState($stateItem) ?? $formattedState ?? $this->formatState($stateItem)); - $copyMessageJs = Js::from($this->getCopyMessage($stateItem)); - $copyMessageDurationJs = Js::from($this->getCopyMessageDuration($stateItem)); - } + if ($isCopyable) { + $copyableStateJs = Js::from($this->getCopyableState($stateItem) ?? $formattedState ?? $this->formatState($stateItem, $relationshipRecord)); + $copyMessageJs = Js::from($this->getCopyMessage($stateItem)); + $copyMessageDurationJs = Js::from($this->getCopyMessageDuration($stateItem)); + } - $tooltip = $this->getTooltip($stateItem); - - return [ - 'attributes' => (new FilamentComponentAttributeBag) - ->class([ - 'fi-ta-text-item', - (($fontFamily = $this->getFontFamily($stateItem)) instanceof FontFamily) ? "fi-font-{$fontFamily->value}" : (is_string($fontFamily) ? $fontFamily : ''), - ]) - ->when( - ! $isBadge, - fn (ComponentAttributeBag $attributes) => $attributes - ->class([ - ($size instanceof TextSize) ? "fi-size-{$size->value}" : $size, - (($weight = $this->getWeight($stateItem)) instanceof FontWeight) ? "fi-font-{$weight->value}" : (is_string($weight) ? $weight : ''), - ]) - ->when($lineClamp, fn (ComponentAttributeBag $attributes) => $attributes->style([ - "--line-clamp: {$lineClamp}", - ])) - ->color(ItemComponent::class, $color) - ), - 'contentAttributes' => ($isBadge || $isCopyable || filled($tooltip)) - ? (new FilamentComponentAttributeBag) - ->merge([ - 'x-on:click.prevent.stop' => $isCopyable - ? <<getTooltip($stateItem, $relationshipRecord); + + return [ + 'attributes' => (new FilamentComponentAttributeBag) + ->class([ + 'fi-ta-text-item', + (($fontFamily = $this->getFontFamily($stateItem)) instanceof FontFamily) ? "fi-font-{$fontFamily->value}" : (is_string($fontFamily) ? $fontFamily : ''), + ]) + ->when( + ! $isBadge, + fn (ComponentAttributeBag $attributes) => $attributes + ->class([ + ($size instanceof TextSize) ? "fi-size-{$size->value}" : $size, + (($weight = $this->getWeight($stateItem)) instanceof FontWeight) ? "fi-font-{$weight->value}" : (is_string($weight) ? $weight : ''), + ]) + ->when($lineClamp, fn (ComponentAttributeBag $attributes) => $attributes->style([ + "--line-clamp: {$lineClamp}", + ])) + ->color(ItemComponent::class, $color) + ), + 'contentAttributes' => ($isBadge || $isCopyable || filled($tooltip)) + ? (new FilamentComponentAttributeBag) + ->merge([ + 'x-on:click.prevent.stop' => $isCopyable + ? << filled($tooltip) - ? '{ + : null, + 'x-tooltip' => filled($tooltip) + ? '{ content: ' . Js::from($tooltip) . ', theme: $store.theme, allowHTML: ' . Js::from($tooltip instanceof Htmlable) . ', }' - : null, - ], escape: false) - ->class([ - 'fi-copyable' => $isCopyable, - ]) - ->when( - $isBadge, - fn (ComponentAttributeBag $attributes) => $attributes - ->class([ - 'fi-badge' => $isBadge, - ($size instanceof TextSize) ? "fi-size-{$size->value}" : $size, - ]) - ->color(BadgeComponent::class, $color ?? 'primary'), - ) - : null, - 'iconAfterHtml' => ($iconPosition === IconPosition::After) ? $iconHtml : '', - 'iconBeforeHtml' => ($iconPosition === IconPosition::Before) ? $iconHtml : '', - ]; + : null, + ], escape: false) + ->class([ + 'fi-copyable' => $isCopyable, + ]) + ->when( + $isBadge, + fn (ComponentAttributeBag $attributes) => $attributes + ->class([ + 'fi-badge' => $isBadge, + ($size instanceof TextSize) ? "fi-size-{$size->value}" : $size, + ]) + ->color(BadgeComponent::class, $color ?? 'primary'), + ) + : null, + 'iconAfterHtml' => ($iconPosition === IconPosition::After) ? $iconHtml : '', + 'iconBeforeHtml' => ($iconPosition === IconPosition::Before) ? $iconHtml : '', + ]; + }); }; $descriptionAbove = $this->getDescriptionAbove(); @@ -480,13 +487,14 @@ public function toEmbeddedHtml(): string (! $lineClamp) ) { $stateItem = Arr::first($state); - $stateItemFormattedState = $isCollapsedList ? null : $this->formatState($stateItem); + $relationshipRecord = $relationshipRecords[0] ?? null; + $stateItemFormattedState = $isCollapsedList ? null : $this->formatState($stateItem, $relationshipRecord); [ 'attributes' => $stateItemAttributes, 'contentAttributes' => $stateItemContentAttributes, 'iconAfterHtml' => $stateItemIconAfterHtml, 'iconBeforeHtml' => $stateItemIconBeforeHtml, - ] = $getStateItem($stateItem, $stateItemFormattedState); + ] = $getStateItem($stateItem, $relationshipRecord, $stateItemFormattedState); ob_start(); ?> @@ -498,7 +506,7 @@ public function toEmbeddedHtml(): string - + @@ -539,13 +547,14 @@ public function toEmbeddedHtml(): string formatState($stateItem); + $relationshipRecord = $relationshipRecords[0] ?? null; + $stateItemFormattedState = $isCollapsedList ? null : $this->formatState($stateItem, $relationshipRecord); [ 'attributes' => $stateItemAttributes, 'contentAttributes' => $stateItemContentAttributes, 'iconAfterHtml' => $stateItemIconAfterHtml, 'iconBeforeHtml' => $stateItemIconBeforeHtml, - ] = $getStateItem($stateItem, $stateItemFormattedState); + ] = $getStateItem($stateItem, $relationshipRecord, $stateItemFormattedState); ?>

toHtml() ?>> @@ -554,7 +563,7 @@ public function toEmbeddedHtml(): string - + @@ -565,14 +574,16 @@ public function toEmbeddedHtml(): string

    - - formatState($stateItem); ?> - $stateItem) { ?> + formatState($stateItem, $relationshipRecord); + [ 'attributes' => $stateItemAttributes, 'contentAttributes' => $stateItemContentAttributes, 'iconAfterHtml' => $stateItemIconAfterHtml, 'iconBeforeHtml' => $stateItemIconBeforeHtml, - ] = $getStateItem($stateItem, $stateItemFormattedState); ?> + ] = $getStateItem($stateItem, $relationshipRecord, $stateItemFormattedState); ?>
  • $listLimit) { ?> @@ -587,7 +598,7 @@ public function toEmbeddedHtml(): string - + @@ -650,14 +661,16 @@ class="fi-link fi-size-xs" ob_start(); ?>
      toHtml() ?>> - - formatState($stateItem); ?> - $stateItem) { ?> + formatState($stateItem, $relationshipRecord); + [ 'attributes' => $stateItemAttributes, 'contentAttributes' => $stateItemContentAttributes, 'iconAfterHtml' => $stateItemIconAfterHtml, 'iconBeforeHtml' => $stateItemIconBeforeHtml, - ] = $getStateItem($stateItem, $stateItemFormattedState); ?> + ] = $getStateItem($stateItem, $relationshipRecord, $stateItemFormattedState); ?>
    • toHtml() ?>> @@ -665,7 +678,7 @@ class="fi-link fi-size-xs" - + diff --git a/tests/src/Fixtures/Livewire/RelationshipRecordUserTable.php b/tests/src/Fixtures/Livewire/RelationshipRecordUserTable.php new file mode 100644 index 00000000000..d74e15185c7 --- /dev/null +++ b/tests/src/Fixtures/Livewire/RelationshipRecordUserTable.php @@ -0,0 +1,200 @@ +query( + User::query() + ->when($this->userId, fn ($query) => $query->whereKey($this->userId)), + ) + ->columns([ + TextColumn::make('teams.name') + ->badge() + ->listWithLineBreaks() + ->color(fn (Team $relationshipRecord): string => $relationshipRecord->name === 'Alpha' ? 'success' : 'danger') + ->tooltip(fn (Team $relationshipRecord): string => "Team #{$relationshipRecord->id}") + ->url(fn (Team $relationshipRecord): string => "/teams/{$relationshipRecord->id}"), + TextColumn::make('team.name') + ->formatStateUsing(fn (string $state, Team $relationshipRecord): string => "{$state} (#{$relationshipRecord->id})"), + TextColumn::make('name'), + ]) + ->paginated(false); + } + + public function render(): View + { + return view('livewire.table'); + } +} + +class RelationshipRecordIconColumnTable extends Component implements HasActions, HasSchemas, Tables\Contracts\HasTable +{ + use InteractsWithActions; + use InteractsWithSchemas; + use Tables\Concerns\InteractsWithTable; + + public ?int $userId = null; + + public function table(Table $table): Table + { + return $table + ->query( + User::query() + ->when($this->userId, fn ($query) => $query->whereKey($this->userId)), + ) + ->columns([ + IconColumn::make('teams.name') + ->listWithLineBreaks() + ->icon(fn (Team $relationshipRecord): Heroicon => $relationshipRecord->name === 'Alpha' ? Heroicon::CheckCircle : Heroicon::XCircle) + ->color(fn (Team $relationshipRecord): string => $relationshipRecord->name === 'Alpha' ? 'success' : 'danger'), + ]) + ->paginated(false); + } + + public function render(): View + { + return view('livewire.table'); + } +} + +class RelationshipRecordPostTable extends Component implements HasActions, HasSchemas, Tables\Contracts\HasTable +{ + use InteractsWithActions; + use InteractsWithSchemas; + use Tables\Concerns\InteractsWithTable; + + public ?int $postId = null; + + public function table(Table $table): Table + { + return $table + ->query( + Post::query() + ->when($this->postId, fn ($query) => $query->whereKey($this->postId)), + ) + ->columns([ + TextColumn::make('author.team.name') + ->color(fn (Team $relationshipRecord): string => $relationshipRecord->name === 'Alpha' ? 'warning' : 'gray'), + ]) + ->paginated(false); + } + + public function render(): View + { + return view('livewire.table'); + } +} + +class RelationshipRecordDistinctListTable extends Component implements HasActions, HasSchemas, Tables\Contracts\HasTable +{ + use InteractsWithActions; + use InteractsWithSchemas; + use Tables\Concerns\InteractsWithTable; + + public ?int $userId = null; + + public function table(Table $table): Table + { + return $table + ->query( + User::query() + ->when($this->userId, fn ($query) => $query->whereKey($this->userId)), + ) + ->columns([ + TextColumn::make('teams.name') + ->distinctList() + ->listWithLineBreaks() + ->color(fn (Team $relationshipRecord): string => "team-{$relationshipRecord->id}"), + ]) + ->paginated(false); + } + + public function render(): View + { + return view('livewire.table'); + } +} + +class RelationshipRecordLimitedListTable extends Component implements HasActions, HasSchemas, Tables\Contracts\HasTable +{ + use InteractsWithActions; + use InteractsWithSchemas; + use Tables\Concerns\InteractsWithTable; + + public ?int $userId = null; + + public function table(Table $table): Table + { + return $table + ->query( + User::query() + ->when($this->userId, fn ($query) => $query->whereKey($this->userId)), + ) + ->columns([ + TextColumn::make('teams.name') + ->badge() + ->listWithLineBreaks() + ->limitList(1) + ->color(fn (Team $relationshipRecord): string => $relationshipRecord->name === 'Alpha' ? 'success' : 'danger'), + ]) + ->paginated(false); + } + + public function render(): View + { + return view('livewire.table'); + } +} + +class RelationshipRecordBelongsToTooltipTable extends Component implements HasActions, HasSchemas, Tables\Contracts\HasTable +{ + use InteractsWithActions; + use InteractsWithSchemas; + use Tables\Concerns\InteractsWithTable; + + public ?int $userId = null; + + public function table(Table $table): Table + { + return $table + ->query( + User::query() + ->when($this->userId, fn ($query) => $query->whereKey($this->userId)), + ) + ->columns([ + TextColumn::make('team.name') + ->tooltip(fn (Team $relationshipRecord): string => "Belongs to team {$relationshipRecord->id}"), + ]) + ->paginated(false); + } + + public function render(): View + { + return view('livewire.table'); + } +} diff --git a/tests/src/Tables/Columns/RelationshipRecordColumnTest.php b/tests/src/Tables/Columns/RelationshipRecordColumnTest.php new file mode 100644 index 00000000000..4649338222c --- /dev/null +++ b/tests/src/Tables/Columns/RelationshipRecordColumnTest.php @@ -0,0 +1,320 @@ +create(['name' => 'Alpha']); + $teamBeta = Team::factory()->create(['name' => 'Beta']); + + $user = User::factory()->create(); + $user->teams()->attach([$teamAlpha->id, $teamBeta->id]); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('teams.name', function (TextColumn $column) use ($user, $teamAlpha, $teamBeta): bool { + $column->record($user); + + $state = Arr::wrap($column->getState()); + $relationshipRecords = $column->getRelationshipRecords(); + + expect($state)->toHaveCount(2) + ->and(collect($state)->sort()->values()->all())->toBe(['Alpha', 'Beta']) + ->and($relationshipRecords)->toHaveCount(2) + ->and(collect($relationshipRecords)->pluck('id')->sort()->values()->all()) + ->toBe(collect([$teamAlpha->id, $teamBeta->id])->sort()->values()->all()); + + return true; + }); + }); + + it('can resolve a single relationship record for belongs-to columns', function (): void { + $team = Team::factory()->create(['name' => 'Alpha']); + $user = User::factory()->create(['team_id' => $team->id]); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('team.name', function (TextColumn $column) use ($user, $team): bool { + $column->record($user); + + expect($column->getState())->toBe('Alpha') + ->and($column->getRelationshipRecords())->toHaveCount(1) + ->and($column->getRelationshipRecord()?->is($team))->toBeTrue(); + + return true; + }); + }); + + it('returns `null` from `getRelationshipRecord()` when multiple related records exist', function (): void { + $user = User::factory()->create(); + $user->teams()->attach(Team::factory()->count(2)->create()->pluck('id')); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('teams.name', function (TextColumn $column) use ($user): bool { + $column->record($user); + + expect($column->getRelationshipRecords())->toHaveCount(2) + ->and($column->getRelationshipRecord())->toBeNull(); + + return true; + }); + }); + + it('returns an empty relationship record list for non-relationship columns', function (): void { + $user = User::factory()->create(['name' => 'Jane']); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('name', function (TextColumn $column) use ($user): bool { + $column->record($user); + + expect($column->getState())->toBe('Jane') + ->and($column->getRelationshipRecords())->toBe([]) + ->and($column->getRelationshipRecord())->toBeNull(); + + return true; + }); + }); + + it('excludes related records with blank relationship state', function (): void { + $teamWithName = Team::factory()->create(['name' => 'Visible']); + $teamWithoutName = Team::factory()->create(['name' => '']); + + $user = User::factory()->create(); + $user->teams()->attach([$teamWithName->id, $teamWithoutName->id]); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('teams.name', function (TextColumn $column) use ($user, $teamWithName): bool { + $column->record($user); + + expect(Arr::wrap($column->getState()))->toBe(['Visible']) + ->and($column->getRelationshipRecords())->toHaveCount(1) + ->and($column->getRelationshipRecords()[0]->is($teamWithName))->toBeTrue(); + + return true; + }); + }); + + it('keeps relationship records aligned when using `distinctList()`', function (): void { + $teamAlpha = Team::factory()->create(['name' => 'Shared']); + $teamBeta = Team::factory()->create(['name' => 'Shared']); + $teamGamma = Team::factory()->create(['name' => 'Unique']); + + $user = User::factory()->create(); + $user->teams()->attach([$teamAlpha->id, $teamBeta->id, $teamGamma->id]); + + livewire(RelationshipRecordDistinctListTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('teams.name', function (TextColumn $column) use ($user, $teamAlpha, $teamGamma): bool { + $column->record($user); + + $state = Arr::wrap($column->getState()); + $relationshipRecords = $column->getRelationshipRecords(); + + expect($state)->toHaveCount(2) + ->and($relationshipRecords)->toHaveCount(2) + ->and($column->getColor($state[0], $relationshipRecords[0]))->toBe("team-{$teamAlpha->id}") + ->and($column->getColor($state[1], $relationshipRecords[1]))->toBe("team-{$teamGamma->id}"); + + return true; + }); + }); + + it('can resolve nested relationship records', function (): void { + $team = Team::factory()->create(['name' => 'Alpha']); + $user = User::factory()->create(['team_id' => $team->id]); + $post = Post::factory()->create(['author_id' => $user->id]); + + livewire(RelationshipRecordPostTable::class, ['postId' => $post->id]) + ->assertTableColumnExists('author.team.name', function (TextColumn $column) use ($post, $team): bool { + $column->record($post); + + expect($column->getState())->toBe('Alpha') + ->and($column->getRelationshipRecord()?->is($team))->toBeTrue() + ->and($column->getColor('Alpha', $column->getRelationshipRecord()))->toBe('warning'); + + return true; + }); + }); +}); + +describe('relationship record closure injection', function (): void { + it('can access the relationship record in `color()` closures', function (): void { + $teamAlpha = Team::factory()->create(['name' => 'Alpha']); + $teamBeta = Team::factory()->create(['name' => 'Beta']); + + $user = User::factory()->create(); + $user->teams()->attach([$teamAlpha->id, $teamBeta->id]); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('teams.name', function (TextColumn $column) use ($user): bool { + $column->record($user); + + $state = Arr::wrap($column->getState()); + $relationshipRecords = $column->getRelationshipRecords(); + + $colors = collect($state) + ->map(fn (string $stateItem, int $index): ?string => $column->getColor($stateItem, $relationshipRecords[$index])) + ->sort() + ->values() + ->all(); + + expect($colors)->toBe(['danger', 'success']); + + return true; + }); + }); + + it('can access the relationship record in `tooltip()` closures', function (): void { + $team = Team::factory()->create(['name' => 'Alpha']); + + $user = User::factory()->create(); + $user->teams()->attach($team); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('teams.name', function (TextColumn $column) use ($user, $team): bool { + $column->record($user); + + expect($column->getTooltip('Alpha', $column->getRelationshipRecord())) + ->toBe("Team #{$team->id}"); + + return true; + }); + }); + + it('can access the relationship record in state-based `url()` closures', function (): void { + $team = Team::factory()->create(['name' => 'Alpha']); + + $user = User::factory()->create(); + $user->teams()->attach($team); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('teams.name', function (TextColumn $column) use ($user, $team): bool { + $column->record($user); + + expect($column->getUrl('Alpha', $column->getRelationshipRecords()[0])) + ->toBe("/teams/{$team->id}"); + + return true; + }); + }); + + it('can access the relationship record in `formatStateUsing()` closures', function (): void { + $team = Team::factory()->create(['name' => 'Alpha']); + $user = User::factory()->create(['team_id' => $team->id]); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('team.name', function (TextColumn $column) use ($user, $team): bool { + $column->record($user); + + expect($column->formatState($team->name, $column->getRelationshipRecord())) + ->toBe("Alpha (#{$team->id})"); + + return true; + }); + }); + + it('can access the relationship record in `icon()` and `color()` closures on icon columns', function (): void { + $teamAlpha = Team::factory()->create(['name' => 'Alpha']); + $teamBeta = Team::factory()->create(['name' => 'Beta']); + + $user = User::factory()->create(); + $user->teams()->attach([$teamAlpha->id, $teamBeta->id]); + + livewire(RelationshipRecordIconColumnTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('teams.name', function (IconColumn $column) use ($user, $teamAlpha, $teamBeta): bool { + $column->record($user); + + $state = Arr::wrap($column->getState()); + $relationshipRecords = $column->getRelationshipRecords(); + + $alphaIndex = collect($relationshipRecords)->search(fn (Team $team): bool => $team->is($teamAlpha)); + $betaIndex = collect($relationshipRecords)->search(fn (Team $team): bool => $team->is($teamBeta)); + + expect($column->getIcon($state[$alphaIndex], $relationshipRecords[$alphaIndex]))->toBe(Heroicon::CheckCircle) + ->and($column->getIcon($state[$betaIndex], $relationshipRecords[$betaIndex]))->toBe(Heroicon::XCircle) + ->and($column->getColor($state[$alphaIndex], $relationshipRecords[$alphaIndex]))->toBe('success') + ->and($column->getColor($state[$betaIndex], $relationshipRecords[$betaIndex]))->toBe('danger'); + + return true; + }); + }); +}); + +describe('relationship record rendering', function (): void { + it('can render many-to-many text badges without errors', function (): void { + $user = User::factory()->create(); + $user->teams()->attach(Team::factory()->count(2)->create()); + + livewire(RelationshipRecordUserTable::class, ['userId' => $user->id]) + ->assertSuccessful() + ->assertCanSeeTableRecords([$user]); + }); + + it('can render icon columns for relationship state without errors', function (): void { + $user = User::factory()->create(); + $user->teams()->attach(Team::factory()->count(2)->create()); + + livewire(RelationshipRecordIconColumnTable::class, ['userId' => $user->id]) + ->assertSuccessful() + ->assertCanSeeTableRecords([$user]); + }); + + it('keeps relationship records aligned when `limitList()` slices rendered state', function (): void { + $teamAlpha = Team::factory()->create(['name' => 'Alpha']); + $teamBeta = Team::factory()->create(['name' => 'Beta']); + + $user = User::factory()->create(); + $user->teams()->attach([$teamAlpha->id, $teamBeta->id]); + + livewire(RelationshipRecordLimitedListTable::class, ['userId' => $user->id]) + ->assertSuccessful() + ->assertTableColumnExists('teams.name', function (TextColumn $column) use ($user, $teamAlpha): bool { + $column->record($user); + + $state = Arr::wrap($column->getState()); + $relationshipRecords = $column->getRelationshipRecords(); + + expect($state)->toHaveCount(2) + ->and($relationshipRecords)->toHaveCount(2); + + $alphaIndex = collect($relationshipRecords)->search(fn (Team $team): bool => $team->is($teamAlpha)); + + expect($column->getColor($state[$alphaIndex], $relationshipRecords[$alphaIndex]))->toBe('success') + ->and($column->getColor($state[1 - $alphaIndex], $relationshipRecords[1 - $alphaIndex]))->toBe('danger'); + + return true; + }); + }); +}); + +describe('relationship record column-level injection', function (): void { + it('can inject `relationshipRecord` by name for belongs-to column closures', function (): void { + $team = Team::factory()->create(['name' => 'Alpha']); + $user = User::factory()->create(['team_id' => $team->id]); + + livewire(RelationshipRecordBelongsToTooltipTable::class, ['userId' => $user->id]) + ->assertTableColumnExists('team.name', function (TextColumn $column) use ($user, $team): bool { + $column->record($user); + + expect($column->getTooltip($team->name))->toBe("Belongs to team {$team->id}"); + + return true; + }); + }); +});