## Summary - add a shared diff presentation layer under `app/Support/Diff` with deterministic row classification, summary derivation, and value stringification - centralize diff-state badge semantics through `BadgeCatalog` with a dedicated `DiffRowStatusBadge` - add reusable Filament diff partials, focused Pest coverage, and the full SpecKit artifact set for spec 141 ## Testing - `vendor/bin/sail artisan test --compact tests/Unit/Support/Diff/DiffRowStatusTest.php tests/Unit/Support/Diff/DiffRowTest.php tests/Unit/Support/Diff/DiffPresenterTest.php tests/Unit/Support/Diff/ValueStringifierTest.php tests/Unit/Badges/DiffRowStatusBadgeTest.php tests/Feature/Support/Diff/SharedDiffSummaryPartialTest.php tests/Feature/Support/Diff/SharedDiffRowPartialTest.php tests/Feature/Support/Diff/SharedInlineListDiffPartialTest.php` - `vendor/bin/sail bin pint --dirty --format agent` ## Filament / Livewire Contract - Livewire v4.0+ compliance: unchanged and respected; this feature adds presentation support only within the existing Filament v5 / Livewire v4 stack - Provider registration: unchanged; no panel/provider changes were required, so `bootstrap/providers.php` remains the correct registration location - Global search: unchanged; no Resource or global-search behavior was added or modified - Destructive actions: none introduced in this feature - Asset strategy: no new registered Filament assets; shared Blade partials rely on the existing asset pipeline and standard deploy step for `php artisan filament:assets` when assets change generally - Testing coverage: presenter, DTOs, stringifier, badge semantics, summary partial, row partial, and inline-list partial are covered by focused Pest unit and feature tests ## Notes - Spec checklist status is complete for `specs/141-shared-diff-presentation-foundation/checklists/requirements.md` - This PR preserves specialized diff renderers and documents incremental adoption rather than forcing migration in the same change Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #170
256 lines
7.0 KiB
PHP
256 lines
7.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Diff;
|
|
|
|
use BackedEnum;
|
|
use Illuminate\Support\Str;
|
|
use Stringable;
|
|
|
|
final class DiffPresenter
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $baseline
|
|
* @param array<string, mixed> $current
|
|
* @param array<int, string> $changedKeys
|
|
* @param array<string, string> $labels
|
|
* @param array<string, array<string, mixed>> $meta
|
|
*/
|
|
public function present(
|
|
array $baseline,
|
|
array $current,
|
|
array $changedKeys = [],
|
|
array $labels = [],
|
|
array $meta = [],
|
|
): DiffPresentation {
|
|
$keys = [];
|
|
|
|
foreach ([array_keys($baseline), array_keys($current), $changedKeys] as $sourceKeys) {
|
|
foreach ($sourceKeys as $key) {
|
|
if (! is_string($key) || trim($key) === '') {
|
|
continue;
|
|
}
|
|
|
|
$keys[$key] = true;
|
|
}
|
|
}
|
|
|
|
if ($keys === []) {
|
|
return DiffPresentation::empty();
|
|
}
|
|
|
|
$changedLookup = [];
|
|
|
|
foreach ($changedKeys as $changedKey) {
|
|
if (! is_string($changedKey) || trim($changedKey) === '') {
|
|
continue;
|
|
}
|
|
|
|
$changedLookup[$changedKey] = true;
|
|
}
|
|
|
|
$rows = [];
|
|
|
|
foreach (array_keys($keys) as $key) {
|
|
$hasOldValue = array_key_exists($key, $baseline);
|
|
$hasNewValue = array_key_exists($key, $current);
|
|
|
|
if (! $hasOldValue && ! $hasNewValue) {
|
|
continue;
|
|
}
|
|
|
|
$oldValue = $baseline[$key] ?? null;
|
|
$newValue = $current[$key] ?? null;
|
|
$isListLike = $this->isListRowCandidate($hasOldValue, $oldValue, $hasNewValue, $newValue);
|
|
[$addedItems, $removedItems, $unchangedItems] = $isListLike
|
|
? $this->prepareListFragments(
|
|
$hasOldValue && is_array($oldValue) ? $oldValue : [],
|
|
$hasNewValue && is_array($newValue) ? $newValue : [],
|
|
)
|
|
: [[], [], []];
|
|
|
|
$rows[] = new DiffRow(
|
|
key: $key,
|
|
label: $this->labelForKey($key, $labels[$key] ?? null),
|
|
status: $this->statusFor(
|
|
key: $key,
|
|
hasOldValue: $hasOldValue,
|
|
hasNewValue: $hasNewValue,
|
|
oldValue: $oldValue,
|
|
newValue: $newValue,
|
|
changedLookup: $changedLookup,
|
|
),
|
|
oldValue: $oldValue,
|
|
newValue: $newValue,
|
|
isListLike: $isListLike,
|
|
addedItems: $addedItems,
|
|
removedItems: $removedItems,
|
|
unchangedItems: $unchangedItems,
|
|
meta: is_array($meta[$key] ?? null) ? $meta[$key] : [],
|
|
);
|
|
}
|
|
|
|
usort($rows, function (DiffRow $left, DiffRow $right): int {
|
|
return [mb_strtolower($left->label), $left->key] <=> [mb_strtolower($right->label), $right->key];
|
|
});
|
|
|
|
return new DiffPresentation(
|
|
summary: DiffSummary::fromRows($rows),
|
|
rows: $rows,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, bool> $changedLookup
|
|
*/
|
|
private function statusFor(
|
|
string $key,
|
|
bool $hasOldValue,
|
|
bool $hasNewValue,
|
|
mixed $oldValue,
|
|
mixed $newValue,
|
|
array $changedLookup,
|
|
): DiffRowStatus {
|
|
if (! $hasOldValue) {
|
|
return DiffRowStatus::Added;
|
|
}
|
|
|
|
if (! $hasNewValue) {
|
|
return DiffRowStatus::Removed;
|
|
}
|
|
|
|
if (array_key_exists($key, $changedLookup) || $oldValue !== $newValue) {
|
|
return DiffRowStatus::Changed;
|
|
}
|
|
|
|
return DiffRowStatus::Unchanged;
|
|
}
|
|
|
|
private function labelForKey(string $key, mixed $label): string
|
|
{
|
|
if (is_string($label) && trim($label) !== '') {
|
|
return trim($label);
|
|
}
|
|
|
|
$segments = preg_split('/\s*>\s*|\./', $key) ?: [];
|
|
$segments = array_values(array_filter(array_map(function (string $segment): string {
|
|
return Str::of($segment)
|
|
->replace(['_', '-'], ' ')
|
|
->headline()
|
|
->trim()
|
|
->toString();
|
|
}, $segments)));
|
|
|
|
if ($segments !== []) {
|
|
return implode(' > ', $segments);
|
|
}
|
|
|
|
return Str::of($key)->headline()->toString();
|
|
}
|
|
|
|
private function isListRowCandidate(
|
|
bool $hasOldValue,
|
|
mixed $oldValue,
|
|
bool $hasNewValue,
|
|
mixed $newValue,
|
|
): bool {
|
|
if ($hasOldValue && $hasNewValue) {
|
|
return $this->isInlineList($oldValue) && $this->isInlineList($newValue);
|
|
}
|
|
|
|
if ($hasOldValue) {
|
|
return $this->isInlineList($oldValue);
|
|
}
|
|
|
|
return $this->isInlineList($newValue);
|
|
}
|
|
|
|
private function isInlineList(mixed $value): bool
|
|
{
|
|
if (! is_array($value) || ! array_is_list($value)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($value as $item) {
|
|
if (! $this->isInlineListItem($item)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function isInlineListItem(mixed $item): bool
|
|
{
|
|
return $item === null
|
|
|| is_scalar($item)
|
|
|| $item instanceof BackedEnum
|
|
|| $item instanceof Stringable;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $baseline
|
|
* @param array<int, mixed> $current
|
|
* @return array{0: array<int, mixed>, 1: array<int, mixed>, 2: array<int, mixed>}
|
|
*/
|
|
private function prepareListFragments(array $baseline, array $current): array
|
|
{
|
|
$remainingBaseline = [];
|
|
|
|
foreach ($baseline as $item) {
|
|
$signature = $this->listItemSignature($item);
|
|
$remainingBaseline[$signature] = ($remainingBaseline[$signature] ?? 0) + 1;
|
|
}
|
|
|
|
$addedItems = [];
|
|
$unchangedItems = [];
|
|
|
|
foreach ($current as $item) {
|
|
$signature = $this->listItemSignature($item);
|
|
|
|
if (($remainingBaseline[$signature] ?? 0) > 0) {
|
|
$unchangedItems[] = $item;
|
|
$remainingBaseline[$signature]--;
|
|
|
|
continue;
|
|
}
|
|
|
|
$addedItems[] = $item;
|
|
}
|
|
|
|
$removedItems = [];
|
|
|
|
foreach ($baseline as $item) {
|
|
$signature = $this->listItemSignature($item);
|
|
|
|
if (($remainingBaseline[$signature] ?? 0) <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$removedItems[] = $item;
|
|
$remainingBaseline[$signature]--;
|
|
}
|
|
|
|
return [$addedItems, $removedItems, $unchangedItems];
|
|
}
|
|
|
|
private function listItemSignature(mixed $item): string
|
|
{
|
|
return serialize($this->normalizeListItem($item));
|
|
}
|
|
|
|
private function normalizeListItem(mixed $item): mixed
|
|
{
|
|
if ($item instanceof BackedEnum) {
|
|
return $item->value;
|
|
}
|
|
|
|
if ($item instanceof Stringable) {
|
|
return (string) $item;
|
|
}
|
|
|
|
return $item;
|
|
}
|
|
}
|