diff --git a/CHANGELOG.md b/CHANGELOG.md index 55216bc..a9edff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Opt-in same-save merge for `fromActivityLog(mergedRenderer: ...)`: rows sharing a non-empty spatie `batch_uuid` collapse into one `TimelineEntry` whose `properties` union every grouped row's payload and whose `renderer` is set explicitly. Read-side only; requires a `batch_uuid` column on `activity_log` (host-owned) +- Merged `properties` now **accumulate** repeated array-valued keys instead of overwriting them: a save touching several entities under the same key (e.g. one `custom_field_changes` row per field) keeps every payload via `array_merge` — list payloads concatenate, associative maps still union per key + ## [1.1.1] - 2026-06-15 ### Added diff --git a/README.md b/README.md index e953848..647382f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A unified chronological timeline for any Eloquent model. Aggregates `spatie/lara - **Per-event renderers** — Blade views, closures, or renderer classes bound per event or type - **Filament-native UX** — infolist component, relation manager, and header-action slide-over - **Dedup + filtering** — type/event allow/deny lists, date windows, priority-based dedup with override +- **Same-save merge** — opt-in grouping of one save's activity rows into a single entry, keyed by spatie `batch_uuid` - **Opt-in caching** — per-call TTL with explicit invalidation — no model observers ## Requirements diff --git a/docs/content/3.essentials/1.sources.md b/docs/content/3.essentials/1.sources.md index 7021977..bcd55e1 100644 --- a/docs/content/3.essentials/1.sources.md +++ b/docs/content/3.essentials/1.sources.md @@ -15,7 +15,7 @@ Sources own where entries come from. The `Relaticle\ActivityLog\Timeline\Timelin Registers `Relaticle\ActivityLog\Timeline\Sources\ActivityLogSource` — the subject's own spatie activity log. ```php -public function fromActivityLog(?int $priority = null): self +public function fromActivityLog(?int $priority = null, ?string $mergedRenderer = null): self ``` ```php @@ -32,6 +32,69 @@ Reads from the `activity_log` table where `subject_type` and `subject_id` match `$priority` overrides the default of `10` (see `source_priorities.activity_log`). +### Same-save merge (opt-in) + +One save can produce several activity rows — e.g. a native `updated` row plus a separate custom-field row. Pass `$mergedRenderer` to collapse rows that share a non-empty `batch_uuid` into **one** `TimelineEntry`: + +```php +$entries = $record->timeline() + ->fromActivityLog(mergedRenderer: 'merged-activity') + ->get(); +``` + +What you get for a merged group: + +- **One entry per batch.** Rows sharing a `batch_uuid` become a single entry; rows without a `batch_uuid` each stay their own entry. +- **Combined `properties`.** The merged entry's `properties` is the union of every grouped row's data (native `attribute_changes` + custom `properties`), so no payload is lost. +- **Explicit `renderer`.** The merged entry's `renderer` is set to your string. `RendererRegistry` resolves `renderer` *before* event/type, so the merge picks a renderer without overriding any global event renderer. Register a renderer for that key (see [/essentials/customization](/essentials/customization)). +- **Read-side only.** This never changes how activities are written/logged — it only groups on read. + +::callout{icon="i-lucide-database" color="info"} +**Requires a `batch_uuid` column.** Rows are grouped by spatie's `batch_uuid`. The package only *reads* this column — the host app owns it. If your `activity_log` table doesn't have it, add it: + +```php +Schema::table('activity_log', function (Blueprint $table): void { + $table->uuid('batch_uuid')->nullable()->index(); +}); +``` + +Stamp one `batch_uuid` per save so related rows group together — via spatie's `Activity::beforeLogging` hook. Rows with a `null`/empty `batch_uuid` are never merged. +:: + +::callout{icon="i-lucide-info" color="info"} +Default `$mergedRenderer = null` keeps the original per-row behaviour — merge is fully opt-in and backward-compatible. `fromActivityLogOf()` does **not** merge yet (tracked as follow-up). +:: + +#### Wiring it end-to-end + +Four steps. The merge itself is one argument — the surrounding work is giving rows a shared `batch_uuid` (write side, host-owned) and binding a renderer to the key (read side). + +**1. Add the `batch_uuid` column** (see the migration above). + +**2. Stamp one `batch_uuid` per save** so the rows from a single save share it. This is host-owned — the package only reads the column. Spatie writes `batch_uuid` for every activity logged inside one batch; use whatever batching mechanism your app already has so all rows from a save get the *same* uuid. Rows left with a `null`/empty `batch_uuid` are never merged and render one-per-row as before. + +**3. Register a renderer for the merge key.** The string you pass as `mergedRenderer` becomes `$entry->renderer`, which the registry resolves **first** — before any event/type binding (see [renderer resolution order](/essentials/customization#renderer-resolution-order)). Bind it through any channel: + +```php +use Relaticle\ActivityLog\Facades\Timeline; + +Timeline::registerRenderer('merged-activity', \App\Timeline\Renderers\MergedActivityRenderer::class); +``` + +**4. Enable the merge** on the builder: + +```php +$entries = $record->timeline() + ->fromActivityLog(mergedRenderer: 'merged-activity') + ->get(); +``` + +#### What the renderer receives + +A merged entry's `properties` is the **union of every grouped row's payload** — native `attribute_changes` (the `attributes` / `old` maps) plus each row's custom `properties`, keyed as each row had them. Repeated **array-valued** keys accumulate instead of overwriting: if one save logs a row per field all under the same key (e.g. `custom_field_changes`), every row's list is concatenated under that key, so nothing is lost. + +The rest of the entry comes from a representative `base` row — the first grouped row that carries non-empty `attribute_changes`, else the first row — so `event`, `title`, `occurredAt`, and `causer` reflect that row. Write your renderer against `$entry->properties` (mixed native + custom keys); the built-in `ActivityLogSummary` helper still works for the native portion (see [customization](/essentials/customization#replacing-the-built-in-activity_log-renderer)). + ## `fromActivityLogOf(array $relations)` Registers `Relaticle\ActivityLog\Timeline\Sources\RelatedActivityLogSource` — spatie activity-log entries that belong to the subject's related models. diff --git a/src/Timeline/Sources/ActivityLogSource.php b/src/Timeline/Sources/ActivityLogSource.php index a885a56..3ed735f 100644 --- a/src/Timeline/Sources/ActivityLogSource.php +++ b/src/Timeline/Sources/ActivityLogSource.php @@ -7,12 +7,30 @@ use Carbon\CarbonImmutable; use DomainException; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Relaticle\ActivityLog\Timeline\TimelineEntry; use Relaticle\ActivityLog\Timeline\Window; use Spatie\Activitylog\Models\Activity as ActivityModel; final class ActivityLogSource extends AbstractTimelineSource { + private bool $mergeSameBatch = false; + + private ?string $mergedRenderer = null; + + /** + * Opt in to collapsing rows that share a non-empty `batch_uuid` into a single + * timeline entry. The optional renderer is set on merged entries so consumers + * can render the grouped save without overriding any global event renderer. + */ + public function withSameBatchMerge(?string $mergedRenderer = null): self + { + $this->mergeSameBatch = true; + $this->mergedRenderer = $mergedRenderer; + + return $this; + } + public function resolve(Model $subject, Window $window): iterable { throw_if($subject->getKey() === null, DomainException::class, 'ActivityLogSource cannot resolve entries for an unsaved subject.'); @@ -33,11 +51,119 @@ public function resolve(Model $subject, Window $window): iterable $query->where('created_at', '<=', $window->to); } - foreach ($query->get() as $activity) { - yield $this->makeEntry($subject, $activity); + $activities = $query->get(); + + if (! $this->mergeSameBatch) { + foreach ($activities as $activity) { + yield $this->makeEntry($subject, $activity); + } + + return; + } + + foreach ($this->groupByBatch($activities) as $group) { + yield $this->makeMergedEntry($subject, $group); } } + /** + * Group rows that share a non-empty `batch_uuid`, preserving the query's + * newest-first order. Rows without a batch_uuid each form their own group. + * First-seen order is the output order. + * + * @param Collection $activities + * @return list> + */ + private function groupByBatch(Collection $activities): array + { + $groups = []; + + foreach ($activities as $activity) { + $batch = $activity->batch_uuid; + $key = ($batch === null || $batch === '') + ? 'id:'.$activity->getKey() + : 'batch:'.$batch; + + $groups[$key][] = $activity; + } + + return array_values($groups); + } + + /** + * @param list $group + */ + private function makeMergedEntry(Model $subject, array $group): TimelineEntry + { + if (count($group) === 1) { + $entry = $this->makeEntry($subject, $group[0]); + + if ($this->mergedRenderer === null) { + return $entry; + } + + return new TimelineEntry( + id: $entry->id, + type: $entry->type, + event: $entry->event, + occurredAt: $entry->occurredAt, + dedupKey: $entry->dedupKey, + sourcePriority: $entry->sourcePriority, + subject: $entry->subject, + causer: $entry->causer, + relatedModel: $entry->relatedModel, + title: $entry->title, + description: $entry->description, + icon: $entry->icon, + color: $entry->color, + renderer: $this->mergedRenderer, + properties: $entry->properties, + ); + } + + $base = null; + foreach ($group as $activity) { + if (($activity->attribute_changes?->toArray() ?? []) !== []) { + $base = $activity; + break; + } + } + $base ??= $group[0]; + + $properties = []; + foreach ($group as $activity) { + foreach ($this->extractProperties($activity) as $key => $value) { + // Repeated array-valued keys must accumulate, not overwrite: a save + // touching several custom fields emits one row each, all under the + // same `custom_field_changes` key — a plain spread would keep only + // the last. array_merge concatenates list payloads (e.g. those + // change lists) while still letting associative maps (native + // `attributes`/`old`) union per field key. + $properties[$key] = isset($properties[$key]) && is_array($properties[$key]) && is_array($value) + ? array_merge($properties[$key], $value) + : $value; + } + } + + $occurredAt = CarbonImmutable::parse($base->created_at); + $event = (string) ($base->event ?? $base->description); + + return new TimelineEntry( + id: sprintf('activity_log:%s:%s', $base->id, $event), + type: 'activity_log', + event: $event, + occurredAt: $occurredAt, + dedupKey: $this->dedupKeyForActivity($subject->getMorphClass(), (string) $subject->getKey(), $occurredAt, (string) $base->getKey()), + sourcePriority: $this->priority, + subject: $subject, + causer: $base->causer, + relatedModel: null, + title: $base->description, + renderer: $this->mergedRenderer, + properties: $properties, + ); + } + private function makeEntry(Model $subject, ActivityModel $activity): TimelineEntry { $occurredAt = CarbonImmutable::parse($activity->created_at); diff --git a/src/Timeline/TimelineBuilder.php b/src/Timeline/TimelineBuilder.php index 392f8d5..91c8e8f 100644 --- a/src/Timeline/TimelineBuilder.php +++ b/src/Timeline/TimelineBuilder.php @@ -58,12 +58,18 @@ public function subject(): Model return $this->subject; } - public function fromActivityLog(?int $priority = null): self + public function fromActivityLog(?int $priority = null, ?string $mergedRenderer = null): self { - $this->sources[] = new ActivityLogSource( + $source = new ActivityLogSource( priority: $priority ?? (int) config('activity-log.source_priorities.activity_log', 10), ); + if ($mergedRenderer !== null) { + $source->withSameBatchMerge($mergedRenderer); + } + + $this->sources[] = $source; + return $this; } diff --git a/tests/Feature/SameBatchMergeTest.php b/tests/Feature/SameBatchMergeTest.php new file mode 100644 index 0000000..5aafb9d --- /dev/null +++ b/tests/Feature/SameBatchMergeTest.php @@ -0,0 +1,168 @@ + 'default', + 'description' => $attributes['description'] ?? 'updated', + 'subject_type' => $person->getMorphClass(), + 'subject_id' => $person->getKey(), + 'event' => $attributes['event'] ?? null, + 'attribute_changes' => $attributes['attribute_changes'] ?? null, + 'properties' => $attributes['properties'] ?? null, + 'batch_uuid' => $attributes['batch_uuid'] ?? null, + ]); +} + +it('merges same-batch rows into one entry carrying both payloads and the merged renderer', function (): void { + $person = Person::factory()->create(); + Activity::query()->delete(); + + $batch = '11111111-1111-1111-1111-111111111111'; + + makeActivity($person, [ + 'event' => 'updated', + 'attribute_changes' => ['attributes' => ['name' => 'New'], 'old' => ['name' => 'Old']], + 'batch_uuid' => $batch, + ]); + makeActivity($person, [ + 'event' => 'custom', + 'description' => 'custom_field_updated', + 'properties' => ['custom_field' => 'value'], + 'batch_uuid' => $batch, + ]); + + $entries = TimelineBuilder::make($person) + ->fromActivityLog(mergedRenderer: 'x') + ->get(); + + expect($entries)->toHaveCount(1); + + $entry = $entries->first(); + + expect($entry->renderer)->toBe('x') + ->and($entry->event)->toBe('updated') + ->and($entry->properties)->toHaveKey('attributes') + ->and($entry->properties)->toHaveKey('old') + ->and($entry->properties)->toHaveKey('custom_field') + ->and($entry->properties['custom_field'])->toBe('value'); +}); + +it('concatenates repeated list-valued properties across grouped rows', function (): void { + $person = Person::factory()->create(); + Activity::query()->delete(); + + $batch = '44444444-4444-4444-4444-444444444444'; + + // One save touching several custom fields emits a separate row per field, each + // under the same `custom_field_changes` key. The merge must keep them all. + makeActivity($person, [ + 'event' => 'custom_field_changes', + 'properties' => ['custom_field_changes' => [['code' => 'icp']]], + 'batch_uuid' => $batch, + ]); + makeActivity($person, [ + 'event' => 'custom_field_changes', + 'properties' => ['custom_field_changes' => [['code' => 'domains']]], + 'batch_uuid' => $batch, + ]); + makeActivity($person, [ + 'event' => 'custom_field_changes', + 'properties' => ['custom_field_changes' => [['code' => 'linkedin']]], + 'batch_uuid' => $batch, + ]); + + $entries = TimelineBuilder::make($person) + ->fromActivityLog(mergedRenderer: 'x') + ->get(); + + expect($entries)->toHaveCount(1); + + $codes = array_map( + static fn (array $change): string => $change['code'], + $entries->first()->properties['custom_field_changes'], + ); + + expect($codes)->toContain('icp') + ->and($codes)->toContain('domains') + ->and($codes)->toContain('linkedin') + ->and($codes)->toHaveCount(3); +}); + +it('does not merge rows with different batch_uuids', function (): void { + $person = Person::factory()->create(); + Activity::query()->delete(); + + makeActivity($person, ['event' => 'updated', 'batch_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa']); + makeActivity($person, ['event' => 'updated', 'batch_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb']); + + $entries = TimelineBuilder::make($person) + ->fromActivityLog(mergedRenderer: 'x') + ->get(); + + expect($entries)->toHaveCount(2); +}); + +it('does not merge rows without a batch_uuid', function (): void { + $person = Person::factory()->create(); + Activity::query()->delete(); + + makeActivity($person, ['event' => 'updated']); + makeActivity($person, ['event' => 'updated']); + + $entries = TimelineBuilder::make($person) + ->fromActivityLog(mergedRenderer: 'x') + ->get(); + + expect($entries)->toHaveCount(2); +}); + +it('preserves newest-first ordering across groups', function (): void { + $person = Person::factory()->create(); + Activity::query()->delete(); + + $older = '11111111-1111-1111-1111-111111111111'; + $newer = '22222222-2222-2222-2222-222222222222'; + + $a = makeActivity($person, ['event' => 'a_one', 'attribute_changes' => ['attributes' => ['name' => '1']], 'batch_uuid' => $older]); + $b = makeActivity($person, ['event' => 'a_two', 'properties' => ['k' => 'v'], 'batch_uuid' => $older]); + $c = makeActivity($person, ['event' => 'b_one', 'batch_uuid' => $newer]); + + Activity::query()->whereKey([$a->id, $b->id])->update(['created_at' => CarbonImmutable::parse('2026-04-17T10:00:00Z')]); + Activity::query()->whereKey($c->id)->update(['created_at' => CarbonImmutable::parse('2026-04-17T11:00:00Z')]); + + $source = (new ActivityLogSource(priority: 10))->withSameBatchMerge('x'); + $entries = collect($source->resolve($person->fresh(), new Window(cap: 10))); + + expect($entries)->toHaveCount(2) + ->and($entries->pluck('event')->all())->toBe(['b_one', 'a_one']); +}); + +it('leaves behavior unchanged when no merged renderer is given', function (): void { + $person = Person::factory()->create(); + Activity::query()->delete(); + + $batch = '11111111-1111-1111-1111-111111111111'; + makeActivity($person, ['event' => 'updated', 'attribute_changes' => ['attributes' => ['name' => 'New']], 'batch_uuid' => $batch]); + makeActivity($person, ['event' => 'custom', 'properties' => ['custom_field' => 'value'], 'batch_uuid' => $batch]); + + $entries = TimelineBuilder::make($person) + ->fromActivityLog() + ->get(); + + expect($entries)->toHaveCount(2) + ->and($entries->pluck('renderer')->unique()->all())->toBe([null]); +});