Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 64 additions & 1 deletion docs/content/3.essentials/1.sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
130 changes: 128 additions & 2 deletions src/Timeline/Sources/ActivityLogSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand All @@ -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<int, ActivityModel> $activities
* @return list<list<ActivityModel>>
*/
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<ActivityModel> $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);
Expand Down
10 changes: 8 additions & 2 deletions src/Timeline/TimelineBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading