diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..98df14e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,37 @@ +name: tests + +on: + push: + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [8.3, 8.4] + laravel: [11.*, 12.*] + include: + - laravel: 11.* + testbench: 9.* + - laravel: 12.* + testbench: 10.* + + name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + composer-options: --with="illuminate/contracts:${{ matrix.laravel }}" --with="orchestra/testbench:${{ matrix.testbench }}" + + - run: composer test + - run: composer analyse diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbc0935 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor +composer.lock +.phpunit.cache +/.idea diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..847dc39 --- /dev/null +++ b/composer.json @@ -0,0 +1,58 @@ +{ + "name": "tapp/filament-activity", + "description": "Activity feed primitives for Filament apps.", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "Tapp\\FilamentActivity\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tapp\\FilamentActivity\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.3", + "illuminate/contracts": "^10.0||^11.0||^12.0", + "filament/filament": "^5.0|^4.0", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "laravel/pint": "^1.14", + "nunomaduro/collision": "^8.1.1||^7.10.0", + "larastan/larastan": "^2.9||^3.0", + "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", + "pestphp/pest": "^2.0||^3.0", + "pestphp/pest-plugin-arch": "^2.5||^3.0", + "pestphp/pest-plugin-laravel": "^2.0||^3.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", + "phpstan/phpstan-phpunit": "^1.3||^2.0" + }, + "extra": { + "laravel": { + "providers": [ + "Tapp\\FilamentActivity\\FilamentActivityServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "scripts": { + "post-autoload-dump": "@composer run prepare", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + } +} diff --git a/config/filament-activity.php b/config/filament-activity.php new file mode 100644 index 0000000..30804f1 --- /dev/null +++ b/config/filament-activity.php @@ -0,0 +1,17 @@ + Activity::class, + + 'tenant' => [ + 'enabled' => false, + 'model' => null, + 'foreign_key' => 'tenant_id', + ], + + 'user' => [ + 'model' => config('auth.providers.users.model'), + ], +]; diff --git a/database/migrations/create_activities_table.php b/database/migrations/create_activities_table.php new file mode 100644 index 0000000..8ee3d4e --- /dev/null +++ b/database/migrations/create_activities_table.php @@ -0,0 +1,33 @@ +id(); + $table->nullableMorphs('actor'); + $table->morphs('subject'); + $table->nullableMorphs('parent'); + $table->string('event'); + $table->string('title')->nullable(); + $table->text('summary')->nullable(); + $table->json('metadata')->nullable(); + $table->foreignId('tenant_id')->nullable()->index(); + $table->timestamp('occurred_at')->index(); + $table->timestamps(); + + $table->index(['tenant_id', 'occurred_at']); + $table->index(['event', 'occurred_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('activities'); + } +}; diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..79b90ad --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,5 @@ +parameters: + paths: + - src + - tests + level: 5 diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..8b644ab --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + ./tests + + + + + ./src + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..93061b6 --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "laravel" +} diff --git a/src/Filament/Resources/ActivityResource.php b/src/Filament/Resources/ActivityResource.php new file mode 100644 index 0000000..474cf68 --- /dev/null +++ b/src/Filament/Resources/ActivityResource.php @@ -0,0 +1,116 @@ +defaultSort('occurred_at', 'desc') + ->columns([ + TextColumn::make('event') + ->label(__('Event')) + ->badge() + ->sortable() + ->searchable(), + TextColumn::make('title') + ->label(__('Title')) + ->searchable() + ->limit(60), + TextColumn::make('actor.name') + ->label(__('Actor')) + ->placeholder(__('System')) + ->sortable(), + TextColumn::make('subject_type') + ->label(__('Subject')) + ->formatStateUsing(fn (?string $state): string => $state ? class_basename($state) : '') + ->sortable(), + TextColumn::make('occurred_at') + ->label(__('Occurred')) + ->since() + ->sortable(), + ]); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->components([ + Grid::make([ + 'default' => 1, + 'md' => 3, + ]) + ->columnSpanFull() + ->schema([ + Section::make(__('Activity')) + ->columnSpan(2) + ->schema([ + TextEntry::make('event')->label(__('Event'))->badge(), + TextEntry::make('title')->label(__('Title'))->placeholder('-'), + TextEntry::make('summary')->label(__('Summary'))->placeholder('-')->columnSpanFull(), + TextEntry::make('metadata') + ->label(__('Metadata')) + ->formatStateUsing(fn (?array $state): string => $state ? json_encode($state, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) : '-') + ->columnSpanFull(), + ]), + Section::make(__('Context')) + ->columnSpan(1) + ->schema([ + TextEntry::make('actor.name')->label(__('Actor'))->placeholder(__('System')), + TextEntry::make('subject_type')->label(__('Subject'))->formatStateUsing(fn (?string $state): string => $state ? class_basename($state) : '-'), + TextEntry::make('parent_type')->label(__('Parent'))->formatStateUsing(fn (?string $state): string => $state ? class_basename($state) : '-'), + TextEntry::make('occurred_at')->label(__('Occurred'))->dateTime(), + ]), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => ListActivities::route('/'), + 'view' => ViewActivity::route('/{record}'), + ]; + } +} diff --git a/src/Filament/Resources/ActivityResource/Pages/ListActivities.php b/src/Filament/Resources/ActivityResource/Pages/ListActivities.php new file mode 100644 index 0000000..43ca5e7 --- /dev/null +++ b/src/Filament/Resources/ActivityResource/Pages/ListActivities.php @@ -0,0 +1,13 @@ +name('filament-activity') + ->hasConfigFile() + ->hasMigration('create_activities_table'); + } + + public function packageRegistered(): void + { + $this->app->singleton(ActivityRecorder::class); + } +} diff --git a/src/Models/Activity.php b/src/Models/Activity.php new file mode 100644 index 0000000..197d845 --- /dev/null +++ b/src/Models/Activity.php @@ -0,0 +1,64 @@ +|null $metadata + * @property int|string|null $tenant_id + * @property Carbon|null $occurred_at + */ +class Activity extends Model +{ + protected $guarded = []; + + protected $casts = [ + 'metadata' => 'array', + 'occurred_at' => 'datetime', + ]; + + public function actor(): MorphTo + { + return $this->morphTo(); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } + + public function parent(): MorphTo + { + return $this->morphTo(); + } + + public function scopeForTenant(Builder $query, Model|int|string|null $tenant): Builder + { + if ($tenant === null) { + return $query->whereNull($this->tenantColumn()); + } + + $tenantKey = $tenant instanceof Model ? $tenant->getKey() : $tenant; + + return $query->where($this->tenantColumn(), $tenantKey); + } + + public function scopeEvent(Builder $query, string|array $event): Builder + { + return $query->whereIn('event', (array) $event); + } + + public function tenantColumn(): string + { + return config('filament-activity.tenant.foreign_key', 'tenant_id'); + } +} diff --git a/src/Models/Traits/HasActivities.php b/src/Models/Traits/HasActivities.php new file mode 100644 index 0000000..28d8449 --- /dev/null +++ b/src/Models/Traits/HasActivities.php @@ -0,0 +1,16 @@ +morphMany(config('filament-activity.model', Activity::class), 'subject'); + } +} diff --git a/src/Services/ActivityRecorder.php b/src/Services/ActivityRecorder.php new file mode 100644 index 0000000..1c49c96 --- /dev/null +++ b/src/Services/ActivityRecorder.php @@ -0,0 +1,53 @@ + $metadata + */ + public function record( + string $event, + Model $subject, + ?Model $actor = null, + ?Model $tenant = null, + ?Model $parent = null, + ?string $title = null, + ?string $summary = null, + array $metadata = [], + ): Activity { + $activityModel = config('filament-activity.model', Activity::class); + $tenantColumn = config('filament-activity.tenant.foreign_key', 'tenant_id'); + + /** @var Activity $activity */ + $activity = new $activityModel; + $activity->forceFill([ + 'event' => $event, + 'title' => $title, + 'summary' => $summary, + 'metadata' => $metadata, + $tenantColumn => $tenant?->getKey(), + 'occurred_at' => now(), + ]); + + $activity->subject()->associate($subject); + + if ($actor) { + $activity->actor()->associate($actor); + } + + if ($parent) { + $activity->parent()->associate($parent); + } + + $activity->save(); + + return $activity; + } +} diff --git a/tests/Database/Migrations/create_posts_table.php b/tests/Database/Migrations/create_posts_table.php new file mode 100644 index 0000000..43bbe59 --- /dev/null +++ b/tests/Database/Migrations/create_posts_table.php @@ -0,0 +1,17 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + } +}; diff --git a/tests/Database/Migrations/create_teams_table.php b/tests/Database/Migrations/create_teams_table.php new file mode 100644 index 0000000..0b9aec3 --- /dev/null +++ b/tests/Database/Migrations/create_teams_table.php @@ -0,0 +1,17 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + } +}; diff --git a/tests/Database/Migrations/create_users_table.php b/tests/Database/Migrations/create_users_table.php new file mode 100644 index 0000000..8914eb7 --- /dev/null +++ b/tests/Database/Migrations/create_users_table.php @@ -0,0 +1,17 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + } +}; diff --git a/tests/Feature/ActivityRecorderTest.php b/tests/Feature/ActivityRecorderTest.php new file mode 100644 index 0000000..862a0a9 --- /dev/null +++ b/tests/Feature/ActivityRecorderTest.php @@ -0,0 +1,54 @@ +create(['name' => 'Team A']); + $actor = User::query()->create(['name' => 'Ada']); + $post = Post::query()->create(['name' => 'First topic']); + $parent = Post::query()->create(['name' => 'Parent topic']); + + $activity = app(ActivityRecorder::class)->record( + event: 'forum.post.created', + subject: $post, + actor: $actor, + tenant: $tenant, + parent: $parent, + title: 'Ada posted First topic', + summary: 'First topic was added to the forum.', + metadata: ['forum_id' => 123], + ); + + expect($activity)->toBeInstanceOf(Activity::class) + ->and($activity->event)->toBe('forum.post.created') + ->and($activity->actor->is($actor))->toBeTrue() + ->and($activity->subject->is($post))->toBeTrue() + ->and($activity->parent->is($parent))->toBeTrue() + ->and($activity->tenant_id)->toBe($tenant->id) + ->and($activity->metadata)->toBe(['forum_id' => 123]) + ->and($activity->occurred_at)->not->toBeNull(); +}); + +it('scopes activities by tenant', function (): void { + $teamA = Team::query()->create(['name' => 'Team A']); + $teamB = Team::query()->create(['name' => 'Team B']); + $post = Post::query()->create(['name' => 'Topic']); + $recorder = app(ActivityRecorder::class); + + $teamAActivity = $recorder->record('forum.post.created', $post, tenant: $teamA); + $recorder->record('forum.post.created', $post, tenant: $teamB); + + expect(Activity::query()->forTenant($teamA)->sole()->id)->toBe($teamAActivity->id); +}); + +it('exposes subject activities through the trait', function (): void { + $post = Post::query()->create(['name' => 'Topic']); + + app(ActivityRecorder::class)->record('forum.post.created', $post); + + expect($post->activities()->count())->toBe(1); +}); diff --git a/tests/Models/Post.php b/tests/Models/Post.php new file mode 100644 index 0000000..503e6df --- /dev/null +++ b/tests/Models/Post.php @@ -0,0 +1,15 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..2fe1b37 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,47 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + $app['config']->set('auth.providers.users.model', User::class); + $app['config']->set('filament-activity.user.model', User::class); + $app['config']->set('filament-activity.tenant.enabled', true); + $app['config']->set('filament-activity.tenant.model', Team::class); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(__DIR__.'/Database/Migrations'); + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + } +}