vendor and env first commit

This commit is contained in:
2025-03-28 08:52:46 +01:00
parent f8388bc81b
commit 8f26283832
10976 changed files with 1349952 additions and 2 deletions
@@ -0,0 +1,3 @@
/.idea
/vendor
composer.lock
+43
View File
@@ -0,0 +1,43 @@
{
"name": "vanguardapp/activity-log",
"description": "User activity log plugin for Vanguard.",
"keywords": [
"laravel",
"vanguard",
"activity log",
"user activity"
],
"license": "MIT",
"authors": [
{
"name": "Milos Stojanovic",
"email": "stojanovic.loshmi@gmail.com",
"homepage": "https://mstojanovic.net",
"role": "Developer"
}
],
"require": {
"php": "^8.0.2|^8.1|^8.2",
"illuminate/http": "^10.0|^11.0",
"vanguardapp/plugins": "^6.0",
"spatie/laravel-query-builder": "^5.0"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": {
"Vanguard\\UserActivity\\": "src/",
"Vanguard\\UserActivity\\Database\\Factories\\": "database/factories/",
"Vanguard\\UserActivity\\Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Vanguard\\UserActivity\\Tests\\": "tests/"
}
},
"config": {
"sort-packages": true
}
}
@@ -0,0 +1,30 @@
<?php
namespace Vanguard\UserActivity\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Vanguard\User;
use Vanguard\UserActivity\Activity;
class ActivityFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Activity::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'description' => substr($this->faker->paragraph, 0, 255),
'ip_address' => $this->faker->ipv4,
'user_agent' => $this->faker->userAgent,
];
}
}
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateUserActivityTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_activity', function (Blueprint $table) {
$table->increments('id');
$table->text('description');
$table->unsignedInteger('user_id');
$table->string('ip_address', 45);
$table->text('user_agent');
$table->timestamp('created_at');
});
Schema::table('user_activity', function (Blueprint $table) {
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
if (DB::getDriverName() != 'sqlite') {
Schema::table('user_activity', function (Blueprint $table) {
$table->dropForeign('user_activity_user_id_foreign');
});
}
Schema::drop('user_activity');
\DB::table('permissions')->where('name', 'users.activity')->delete();
}
}
@@ -0,0 +1,29 @@
<?php
namespace Vanguard\UserActivity\Database\Seeders;
use Illuminate\Database\Seeder;
use Vanguard\Permission;
use Vanguard\Role;
class ActivityPermissionsSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$adminRole = Role::where('name', 'Admin')->first();
$permission = Permission::create([
'name' => 'users.activity',
'display_name' => 'View System Activity Log',
'description' => 'View activity log for all system users.',
'removable' => false,
]);
$adminRole->attachPermission($permission);
}
}
+117
View File
@@ -0,0 +1,117 @@
User Activity Log plugin for [Vanguard - Advanced PHP Login and User Management](https://vanguardapp.io)
system.
This plugin was originally part of the Vanguard itself, but it has been extracted as a separate plugin starting from Vanguard 4.
## Installation
This plugin requires Vanguard `5.0.0` or greater.
### Installation via Composer
To install the plugin first you will need to pull it via composer
by running the following command
```
composer require vanguardapp/activity-log
```
The composer will install the plugin for you as well as it's dependencies.
The next step is to register the plugin by adding the
`\Vanguard\UserActivity\UserActivity::class`
to the list of Vanguard plugins inside the `VanguardServiceProvider`:
```php
protected function plugins()
{
return [
//...
\Vanguard\UserActivity\UserActivity::class,
];
}
```
As soon as your plugin is registered, you should publish the
plugins migrations by running the following command:
```
php artisan vendor:publish --provider="Vanguard\UserActivity\UserActivity" --tag="migrations"
```
And, as the last step of the installation, you will need to
run the following commands to make all the necessary database modifications:
```
php artisan migrate
php artisan db:seed --class="ActivityPermissionsSeeder"
```
At this point the plugin will be fully installed and ready to go.
It is configured to listen for most of the events that are coming from
Vanguard and to put the into the activity log.
### Manual Installation
If you plan to make the modifications to the plugin and customize it to
fit your needs, it's much easier if you add it to your project manually.
To do so, you will need to download the ZIP archive from GitHub
by clicking the green "Clone or download" button and then choosing
the "Download ZIP" option from the dropdown.
Once you have the ZIP file on your computer, extract it to the
`plugins/ActivityLog` folder (you will need to create this folder
since it probably won't be present in your Vanguard installation).
Next step is to update your main `composer.json` file located in
Vanguard's root directory and add the following object to the `repositories`
array:
```
{
"type": "path",
"url": "./plugins/ActivityLog"
}
```
This will tell the composer that your plugin is located in `/plugins/ActivityLog`
directory and that it should be installed from there.
Now, add the following to the composer's `require` section
```
"vanguardapp/activity-log": "*"
```
And run `composer update`.
Composer will now install the plugin from your local directory instead
of pulling it from GitHub, which means that you will be able to make
the changes to the plugin itself and customize it to fit your needs.
The rest of the process is the same as when the plugin is installed
by directly fetching it via composer from the GitHub repository, so you
will need to do all the same steps as above, which in short involves
updating the `VanguardServiceProvider` and running the commands to
publish plugin's static assets and to update the database.
## Dashboard Widgets
A plugin provides user activity dashboard widget that is visible for all users with a role `User`.
To activate the widget add the `Vanguard\UserActivity\Widgets\ActivityWidget::class` to the widgets array in `VanguardServiceProvider`:
```php
protected function widgets()
{
return [
//...
\Vanguard\UserActivity\Widgets\ActivityWidget::class,
];
}
```
## License
This plugin is an open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+15
View File
@@ -0,0 +1,15 @@
{
"Activity Log": "Aktivitäts-Log",
"Search for Action": "Nach einer Aktion suchen...",
"User": "Benutzer",
"Message": "Nachricht",
"Log Time": "Log-Zeitpunkt",
"More Info": "Mehr Infos",
"View Activity Log": "Aktivitätslog ansehen",
"No activity from this user yet.": "Keine Aktivität von diesem Benutzer noch nicht.",
"Action": "Aktion",
"Date": "Datum",
"Latest Activity": "Letzte Aktivitäten",
"Complete Activity Log": "Vollständiges Aktivitäts-Log",
"View All": "Alle ansehen"
}
@@ -0,0 +1,32 @@
<?php
return [
'new_permission' => 'Neue Berechtigung mit dem Namen :name wurde erstellt.',
'updated_permission' => 'Berechtigung mit dem Namen :name wurde aktualisiert.',
'deleted_permission' => 'Berechtigung mit dem Namen :name wurde gelöscht.',
'new_role' => 'Neue Benutzerrolle mit dem Namen :name wurde erstellt.',
'updated_role' => 'Benutzerrolle mit dem Namen :name wurde aktualisiert.',
'deleted_role' => 'Benutzerrolle mit dem Namen :name wurde gelöscht.',
'updated_role_permissions' => 'Rollen Benutzerberechtigungen wurden aktualisiert.',
'logged_in' => 'Angemeldet.',
'logged_out' => 'Abgemeldet.',
'created_account' => 'Ein Benutzerkonto wurde erstellt.',
'updated_avatar' => 'Der Profil-Avatar wurde aktualisiert.',
'updated_profile' => 'Die Profil-Details wurden aktualisiert.',
'deleted_user' => 'Benutzer :name wurde gelöscht.',
'banned_user' => 'Benutzer :name wurde gesperrt.',
'updated_profile_details_for' => 'Profildetails für Benutzer :name wurden aktualisiert.',
'created_account_for' => 'Ein Benutzerkonto für Benutzer :name wurde erstellt.',
'updated_settings' => 'WebSeiten Einstellungen wurden aktualisiert.',
'enabled_2fa' => 'Zwei-Faktor Authentifizierung wurde aktiviert.',
'disabled_2fa' => 'Zwei-Faktor Authentifizierung wurde deaktiviert.',
'enabled_2fa_for' => 'Zwei-Faktor Authentifizierung für Benutzer :name wurde aktiviert.',
'disabled_2fa_for' => 'Zwei-Faktor Authentifizierung für Benutzer :name wurde deaktiviert.',
'requested_password_reset' => 'Mail zum Zurücksetzen des Passworts wurde angefordert.',
'reseted_password' => 'Das Passwort wurde mit Hilfe der Option "Passwort vergessen" zurückgesetzt.',
'started_impersonating' => 'Gestartet Identitätswechsel Benutzer :name (ID: :id)',
'stopped_impersonating' => 'Gestoppt Identitätswechsel Benutzer :name (ID: :id)',
];
+15
View File
@@ -0,0 +1,15 @@
{
"Activity Log": "",
"Search for Action": "",
"User": "",
"Message": "",
"Log Time": "",
"More Info": "",
"View Activity Log": "",
"No activity from this user yet.": "",
"Action": "",
"Date": "",
"Latest Activity": "",
"Complete Activity Log": "",
"View All": ""
}
@@ -0,0 +1,32 @@
<?php
return [
'new_permission' => 'Created new permission called :name.',
'updated_permission' => 'Updated the permission named :name.',
'deleted_permission' => 'Deleted permission named :name.',
'new_role' => 'Created new role called :name.',
'updated_role' => 'Updated role with name :name.',
'deleted_role' => 'Deleted role named :name.',
'updated_role_permissions' => 'Updated role permissions.',
'logged_in' => 'Logged in.',
'logged_out' => 'Logged out.',
'created_account' => 'Created an account.',
'updated_avatar' => 'Updated profile avatar.',
'updated_profile' => 'Updated profile details.',
'deleted_user' => 'Deleted user :name.',
'banned_user' => 'Banned user :name.',
'updated_profile_details_for' => 'Updated profile details for :name.',
'created_account_for' => 'Created an account for user :name.',
'updated_settings' => 'Updated website settings.',
'enabled_2fa' => 'Enabled Two-Factor Authentication.',
'disabled_2fa' => 'Disabled Two-Factor Authentication.',
'enabled_2fa_for' => 'Enabled Two-Factor Authentication for user :name.',
'disabled_2fa_for' => 'Disabled Two-Factor Authentication for user :name.',
'requested_password_reset' => 'Requested password reset email.',
'reseted_password' => 'Reseted password using "Forgot Password" option.',
'started_impersonating' => 'Started impersonating user :name (ID: :id)',
'stopped_impersonating' => 'Stopped impersonating user :name (ID: :id)',
];
+15
View File
@@ -0,0 +1,15 @@
{
"Activity Log": "Aktivnost",
"Search for Action": "Pretraži akcije",
"User": "Korisnik",
"Message": "Poruka",
"Log Time": "Vreme kreiranja",
"More Info": "Više informacija",
"View Activity Log": "Pregled aktivnosti",
"No activity from this user yet.": "Još uvek nema aktivnosti od strane ovog korisnika.",
"Action": "Akcija",
"Date": "Datum",
"Latest Activity": "Poslednja aktivnost",
"Complete Activity Log": "Kompletna aktivnost",
"View All": "Vidi sve"
}
@@ -0,0 +1,32 @@
<?php
return [
'new_permission' => 'Kreirao je novu dozvolu pod nazivom :name.',
'updated_permission' => 'Ažurirao je dozvolu pod nazivom :name.',
'deleted_permission' => 'Obrisao je dozvolu pod nazivom :name.',
'new_role' => 'Kreirao je novu ulogu pod nazivom :name.',
'updated_role' => 'Ažurirao je ulogu pod nazivom :name.',
'deleted_role' => 'Obrisao je ulogu pod nazivom :name.',
'updated_role_permissions' => 'Ažurirao je dozvole svih uloga.',
'logged_in' => 'Prijavio se.',
'logged_out' => 'Odjavio se.',
'created_account' => 'Napravio je nalog.',
'updated_avatar' => 'Ažurirao je profilnu sliku.',
'updated_profile' => 'Ažurirao je informacije na profilu.',
'deleted_user' => 'Obrisao je korisnika :name.',
'banned_user' => 'Blokirao je korisnika :name.',
'updated_profile_details_for' => 'Ažurirao je informacije o profilu korisnika :name.',
'created_account_for' => 'Obrisao je nalog korisnika :name.',
'updated_settings' => 'Ažurirao je podešavanja aplkacije.',
'enabled_2fa' => 'Aktivirao je Two-Factor autentifikaciju.',
'disabled_2fa' => 'Deaktivirao je Two-Factor autentifikaciju.',
'enabled_2fa_for' => 'Aktivirao je Two-Factor autentifikaciju za korisnika :name.',
'disabled_2fa_for' => 'Disabled Two-Factor autentifikaciju za korisnika :name.',
'requested_password_reset' => 'Zatražio je e-mail za obnavljanje lozinke.',
'reseted_password' => 'Obnovio je lozinku korišćenjem opcije "Zaboravljena lozinka".',
'started_impersonating' => 'Započeo je lažno predstavljanje korisnika :name (ID: :id)',
'stopped_impersonating' => 'Završio je sa lažnim predstavljanjem korisnika :name (ID: :id)',
];
@@ -0,0 +1,100 @@
@extends('layouts.app')
@section('page-title', __('Activity Log'))
@section('page-heading', isset($user) ? $user->present()->nameOrEmail : __('Activity Log'))
@section('breadcrumbs')
@if (isset($user) && isset($adminView))
<li class="breadcrumb-item">
<a href="{{ route('activity.index') }}">@lang('Activity Log')</a>
</li>
<li class="breadcrumb-item active">
{{ $user->present()->nameOrEmail }}
</li>
@else
<li class="breadcrumb-item active">
@lang('Activity Log')
</li>
@endif
@stop
@section('content')
<div class="card">
<div class="card-body">
<form action="" method="GET" id="users-form" class="border-bottom-light mb-3">
<div class="row justify-content-between mt-3 mb-4">
<div class="col-lg-5 col-md-6">
<div class="input-group custom-search-form">
<input type="text"
class="form-control input-solid"
name="search"
value="{{ Request::get('search') }}"
placeholder="@lang('Search for Action')">
<span class="input-group-append">
@if (Request::has('search') && Request::get('search') != '')
<a href="{{ isset($adminView) ? route('activity.index') : route('profile.activity') }}"
class="btn btn-light d-flex align-items-center"
role="button">
<i class="fas fa-times text-muted"></i>
</a>
@endif
<button class="btn btn-light" type="submit" id="search-activities-btn">
<i class="fas fa-search text-muted"></i>
</button>
</span>
</div>
</div>
</div>
</form>
<div class="table-responsive">
<table class="table table-borderless table-striped">
<thead>
@if (isset($adminView))
<th class="min-width-150">@lang('User')</th>
@endif
<th>@lang('IP Address')</th>
<th class="min-width-200">@lang('Message')</th>
<th class="min-width-200">@lang('Log Time')</th>
<th class="text-center">@lang('More Info')</th>
</thead>
<tbody>
@foreach ($activities as $activity)
<tr>
@if (isset($adminView))
<td>
@if (isset($user))
{{ $activity->user->present()->nameOrEmail }}
@else
<a href="{{ route('activity.user', $activity->user_id) }}"
data-toggle="tooltip" title="@lang('View Activity Log')">
{{ $activity->user->present()->nameOrEmail }}
</a>
@endif
</td>
@endif
<td>{{ $activity->ip_address }}</td>
<td>{{ $activity->description }}</td>
<td>{{ $activity->created_at->format(config('app.date_time_format')) }}</td>
<td class="text-center">
<a tabindex="0" role="button" class="btn btn-icon"
data-trigger="focus"
data-placement="left"
data-toggle="popover"
title="@lang('User Agent')"
data-content="{{ $activity->user_agent }}">
<i class="fas fa-info-circle"></i>
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
{!! $activities->render() !!}
@stop
@@ -0,0 +1,42 @@
<div class="card">
<h6 class="card-header d-flex align-items-center justify-content-between">
@lang('Latest Activity')
@if (count($activities))
<small>
<a href="{{ route('activity.user', $user->id) }}"
class="edit"
data-toggle="tooltip"
data-placement="top"
title="@lang('Complete Activity Log')">
@lang('View All')
</a>
</small>
@endif
</h6>
<div class="card-body">
@if (count($activities))
<table class="table table-borderless table-striped">
<thead>
<tr>
<th>@lang('Action')</th>
<th>@lang('Date')</th>
</tr>
</thead>
<tbody>
@foreach($activities as $activity)
<tr>
<td>{{ $activity->description }}</td>
<td>{{ $activity->created_at->format(config('app.date_time_format')) }}</td>
</tr>
@endforeach
</tbody>
</table>
@else
<p class="text-muted font-weight-light">
<em>@lang('No activity from this user yet.')</em>
</p>
@endif
</div>
</div>
@@ -0,0 +1,11 @@
<script>
var labels = @json(array_keys($activities));
var activities = @json(array_values($activities));
var trans = {
chartLabel: "{{ __('Registration History') }}",
action: "{{ __('action') }}",
actions: "{{ __('actions') }}"
};
</script>
<script src="{{ asset('assets/js/chart.min.js') }}"></script>
<script src="{{ asset('assets/js/as/dashboard-default.js') }}"></script>
@@ -0,0 +1,11 @@
<div class="card">
<h6 class="card-header">
@lang('Activity') (@lang('Last Two Weeks'))
</h6>
<div class="card-body">
<div class="pt-4 px-3">
<canvas id="myChart" height="400"></canvas>
</div>
</div>
</div>
+4
View File
@@ -0,0 +1,4 @@
<?php
Route::get('/activity', 'ActivityController@index');
Route::get('/stats/activity', 'StatsController@show');
+12
View File
@@ -0,0 +1,12 @@
<?php
Route::group(['middleware' => ['auth', 'verified']], function () {
Route::get('profile/activity', 'UserActivityController@show')->name('profile.activity');
Route::get('activity', 'ActivityController@index')->name('activity.index')
->middleware('permission:users.activity');
Route::get('activity/user/{user}/log', 'UserActivityController@index')->name('activity.user')
->middleware('permission:users.activity');
});
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Vanguard\UserActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Vanguard\User;
use Vanguard\UserActivity\Database\Factories\ActivityFactory;
class Activity extends Model
{
use HasFactory;
const UPDATED_AT = null;
protected $table = 'user_activity';
protected $fillable = ['description', 'user_id', 'ip_address', 'user_agent'];
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Create a new factory instance for the model.
*/
protected static function newFactory(): ActivityFactory
{
return new ActivityFactory;
}
}
@@ -0,0 +1,37 @@
<?php
namespace Vanguard\UserActivity\Http\Controllers\Api;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\QueryBuilder;
use Vanguard\Http\Controllers\Api\ApiController;
use Vanguard\UserActivity\Activity;
use Vanguard\UserActivity\Http\Requests\GetActivitiesRequest;
use Vanguard\UserActivity\Http\Resources\ActivityResource;
class ActivityController extends ApiController
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('permission:users.activity');
}
/**
* Paginate user activities.
*/
public function index(GetActivitiesRequest $request): \Illuminate\Http\Resources\Json\AnonymousResourceCollection
{
$activities = QueryBuilder::for(Activity::class)
->allowedIncludes('user')
->allowedFilters([
AllowedFilter::partial('description'),
AllowedFilter::exact('user', 'user_id'),
])
->allowedSorts('created_at')
->defaultSort('-created_at')
->paginate($request->per_page ?: 20);
return ActivityResource::collection($activities);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Vanguard\UserActivity\Http\Controllers\Api;
use Auth;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Vanguard\Http\Controllers\Api\ApiController;
use Vanguard\UserActivity\Repositories\Activity\ActivityRepository;
class StatsController extends ApiController
{
public function __construct(private readonly ActivityRepository $activities)
{
$this->middleware('auth');
}
public function show(): JsonResponse
{
$data = $this->activities->userActivityForPeriod(
Auth::user()->id,
Carbon::now()->subWeeks(2),
Carbon::now()
);
return response()->json($data);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Vanguard\UserActivity\Http\Controllers\Web;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Vanguard\Http\Controllers\Controller;
use Vanguard\UserActivity\Repositories\Activity\ActivityRepository;
class ActivityController extends Controller
{
public function __construct(private readonly ActivityRepository $activities)
{
}
/**
* Displays the page with activities for all system users.
*/
public function index(Request $request): View
{
$activities = $this->activities->paginateActivities(perPage: 20, search: $request->search);
return view('user-activity::index', [
'adminView' => true,
'activities' => $activities,
]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Vanguard\UserActivity\Http\Controllers\Web;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Vanguard\Http\Controllers\Controller;
use Vanguard\User;
use Vanguard\UserActivity\Repositories\Activity\ActivityRepository;
class UserActivityController extends Controller
{
public function __construct(private readonly ActivityRepository $activities)
{
}
/**
* Displays the activity log page for specific user.
*/
public function index(User $user, Request $request): View
{
$activities = $this->activities->paginateActivitiesForUser(
userId: $user->id,
search: $request->search,
);
return view('user-activity::index', [
'user' => $user,
'adminView' => true,
'activities' => $activities,
]);
}
/**
* Display user activity log.
*/
public function show(Request $request): View
{
$user = auth()->user();
$activities = $this->activities->paginateActivitiesForUser(
userId: $user->id,
search: $request->get('search'),
);
return view('user-activity::index', compact('activities', 'user'));
}
}
@@ -0,0 +1,25 @@
<?php
namespace Vanguard\UserActivity\Http\Requests;
use Vanguard\Http\Requests\Request;
class GetActivitiesRequest extends Request
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'per_page' => 'integer|max:100',
];
}
public function messages(): array
{
return [
'per_page.max' => __('Maximum number of records per page is 100.'),
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace Vanguard\UserActivity\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class ActivityResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
*/
public function toArray($request): array
{
$agent = app('agent');
$agent->setUserAgent($this->resource->user_agent);
return [
'id' => (int) $this->id,
'user_id' => (int) $this->user_id,
'ip_address' => $this->ip_address,
'user_agent' => $this->user_agent,
'browser' => $agent->browser(),
'platform' => $agent->platform(),
'device' => $agent->device(),
'description' => $this->description,
'created_at' => (string) $this->created_at,
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace Vanguard\UserActivity\Http\View\Composers;
use Illuminate\View\View;
use Vanguard\UserActivity\Repositories\Activity\ActivityRepository;
class ShowUserComposer
{
public function __construct(private readonly ActivityRepository $activity)
{
}
public function compose(View $view): void
{
$user = $view->getData()['user'];
$view->with('activities', $this->activity->getLatestActivitiesForUser($user->id));
}
}
@@ -0,0 +1,58 @@
<?php
namespace Vanguard\UserActivity\Listeners;
use Illuminate\Events\Dispatcher;
use Vanguard\Events\Permission\Created;
use Vanguard\Events\Permission\Deleted;
use Vanguard\Events\Permission\Updated;
use Vanguard\UserActivity\Logger;
class PermissionEventsSubscriber
{
public function __construct(private readonly Logger $logger)
{
}
public function onCreate(Created $event): void
{
$permission = $event->getPermission();
$name = $permission->display_name ?: $permission->name;
$message = trans('user-activity::log.new_permission', ['name' => $name]);
$this->logger->log($message);
}
public function onUpdate(Updated $event): void
{
$permission = $event->getPermission();
$name = $permission->display_name ?: $permission->name;
$message = trans('user-activity::log.updated_permission', ['name' => $name]);
$this->logger->log($message);
}
public function onDelete(Deleted $event): void
{
$permission = $event->getPermission();
$name = $permission->display_name ?: $permission->name;
$message = trans('user-activity::log.deleted_permission', ['name' => $name]);
$this->logger->log($message);
}
/**
* Register the listeners for the subscriber.
*/
public function subscribe(Dispatcher $events): void
{
$class = self::class;
$events->listen(Created::class, "{$class}@onCreate");
$events->listen(Updated::class, "{$class}@onUpdate");
$events->listen(Deleted::class, "{$class}@onDelete");
}
}
@@ -0,0 +1,65 @@
<?php
namespace Vanguard\UserActivity\Listeners;
use Illuminate\Events\Dispatcher;
use Vanguard\Events\Role\Created;
use Vanguard\Events\Role\Deleted;
use Vanguard\Events\Role\PermissionsUpdated;
use Vanguard\Events\Role\Updated;
use Vanguard\UserActivity\Logger;
class RoleEventsSubscriber
{
public function __construct(private readonly Logger $logger)
{
}
public function onCreate(Created $event): void
{
$message = trans(
'user-activity::log.new_role',
['name' => $event->getRole()->display_name]
);
$this->logger->log($message);
}
public function onUpdate(Updated $event): void
{
$message = trans(
'user-activity::log.updated_role',
['name' => $event->getRole()->display_name]
);
$this->logger->log($message);
}
public function onDelete(Deleted $event): void
{
$message = trans(
'user-activity::log.deleted_role',
['name' => $event->getRole()->display_name]
);
$this->logger->log($message);
}
public function onPermissionsUpdate(PermissionsUpdated $event): void
{
$this->logger->log(trans('user-activity::log.updated_role_permissions'));
}
/**
* Register the listeners for the subscriber.
*/
public function subscribe(Dispatcher $events): void
{
$class = self::class;
$events->listen(Created::class, "{$class}@onCreate");
$events->listen(Updated::class, "{$class}@onUpdate");
$events->listen(Deleted::class, "{$class}@onDelete");
$events->listen(PermissionsUpdated::class, "{$class}@onPermissionsUpdate");
}
}
@@ -0,0 +1,195 @@
<?php
namespace Vanguard\UserActivity\Listeners;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Registered;
use Illuminate\Events\Dispatcher;
use Lab404\Impersonate\Events\LeaveImpersonation;
use Lab404\Impersonate\Events\TakeImpersonation;
use Vanguard\Events\Settings\Updated as SettingsUpdated;
use Vanguard\Events\User\Banned;
use Vanguard\Events\User\ChangedAvatar;
use Vanguard\Events\User\Created;
use Vanguard\Events\User\Deleted;
use Vanguard\Events\User\LoggedIn;
use Vanguard\Events\User\LoggedOut;
use Vanguard\Events\User\RequestedPasswordResetEmail;
use Vanguard\Events\User\TwoFactorDisabled;
use Vanguard\Events\User\TwoFactorDisabledByAdmin;
use Vanguard\Events\User\TwoFactorEnabled;
use Vanguard\Events\User\TwoFactorEnabledByAdmin;
use Vanguard\Events\User\UpdatedByAdmin;
use Vanguard\Events\User\UpdatedProfileDetails;
use Vanguard\UserActivity\Logger;
class UserEventsSubscriber
{
public function __construct(private readonly Logger $logger)
{
}
public function onLogin(LoggedIn $event): void
{
$this->logger->log(trans('user-activity::log.logged_in'));
}
public function onLogout(LoggedOut $event): void
{
$this->logger->log(trans('user-activity::log.logged_out'));
}
public function onRegister(Registered $event): void
{
$this->logger->setUser($event->user);
$this->logger->log(trans('user-activity::log.created_account'));
}
public function onAvatarChange(ChangedAvatar $event): void
{
$this->logger->log(trans('user-activity::log.updated_avatar'));
}
public function onProfileDetailsUpdate(UpdatedProfileDetails $event): void
{
$this->logger->log(trans('user-activity::log.updated_profile'));
}
public function onDelete(Deleted $event): void
{
$message = trans(
'user-activity::log.deleted_user',
['name' => $event->getDeletedUser()->present()->nameOrEmail]
);
$this->logger->log($message);
}
public function onBan(Banned $event): void
{
$message = trans(
'user-activity::log.banned_user',
['name' => $event->getBannedUser()->present()->nameOrEmail]
);
$this->logger->log($message);
}
public function onUpdateByAdmin(UpdatedByAdmin $event): void
{
$message = trans(
'user-activity::log.updated_profile_details_for',
['name' => $event->getUpdatedUser()->present()->nameOrEmail]
);
$this->logger->log($message);
}
public function onCreate(Created $event): void
{
$message = trans(
'user-activity::log.created_account_for',
['name' => $event->getCreatedUser()->present()->nameOrEmail]
);
$this->logger->log($message);
}
public function onSettingsUpdate(SettingsUpdated $event): void
{
$this->logger->log(trans('user-activity::log.updated_settings'));
}
public function onTwoFactorEnable(TwoFactorEnabled $event): void
{
$this->logger->log(trans('user-activity::log.enabled_2fa'));
}
public function onTwoFactorDisable(TwoFactorDisabled $event): void
{
$this->logger->log(trans('user-activity::log.disabled_2fa'));
}
public function onTwoFactorEnableByAdmin(TwoFactorEnabledByAdmin $event): void
{
$message = trans(
'user-activity::log.enabled_2fa_for',
['name' => $event->getUser()->present()->nameOrEmail]
);
$this->logger->log($message);
}
public function onTwoFactorDisableByAdmin(TwoFactorDisabledByAdmin $event): void
{
$message = trans(
'user-activity::log.disabled_2fa_for',
['name' => $event->getUser()->present()->nameOrEmail]
);
$this->logger->log($message);
}
public function onPasswordResetEmailRequest(RequestedPasswordResetEmail $event): void
{
$this->logger->setUser($event->getUser());
$this->logger->log(trans('user-activity::log.requested_password_reset'));
}
public function onPasswordReset(PasswordReset $event): void
{
$this->logger->setUser($event->user);
$this->logger->log(trans('user-activity::log.reseted_password'));
}
public function onStartImpersonating(TakeImpersonation $event): void
{
$this->logger->setUser($event->impersonator);
$message = trans('user-activity::log.started_impersonating', [
'id' => $event->impersonated->id,
'name' => $event->impersonated->present()->name,
]);
$this->logger->log($message);
}
public function onStopImpersonating(LeaveImpersonation $event): void
{
$this->logger->setUser($event->impersonator);
$message = trans('user-activity::log.stopped_impersonating', [
'id' => $event->impersonated->id,
'name' => $event->impersonated->present()->name,
]);
$this->logger->log($message);
}
/**
* Register the listeners for the subscriber.
*/
public function subscribe(Dispatcher $events)
{
$class = self::class;
$events->listen(LoggedIn::class, "{$class}@onLogin");
$events->listen(LoggedOut::class, "{$class}@onLogout");
$events->listen(Registered::class, "{$class}@onRegister");
$events->listen(Created::class, "{$class}@onCreate");
$events->listen(ChangedAvatar::class, "{$class}@onAvatarChange");
$events->listen(UpdatedProfileDetails::class, "{$class}@onProfileDetailsUpdate");
$events->listen(UpdatedByAdmin::class, "{$class}@onUpdateByAdmin");
$events->listen(Deleted::class, "{$class}@onDelete");
$events->listen(Banned::class, "{$class}@onBan");
$events->listen(SettingsUpdated::class, "{$class}@onSettingsUpdate");
$events->listen(TwoFactorEnabled::class, "{$class}@onTwoFactorEnable");
$events->listen(TwoFactorDisabled::class, "{$class}@onTwoFactorDisable");
$events->listen(TwoFactorEnabledByAdmin::class, "{$class}@onTwoFactorEnableByAdmin");
$events->listen(TwoFactorDisabledByAdmin::class, "{$class}@onTwoFactorDisableByAdmin");
$events->listen(RequestedPasswordResetEmail::class, "{$class}@onPasswordResetEmailRequest");
$events->listen(PasswordReset::class, "{$class}@onPasswordReset");
$events->listen(TakeImpersonation::class, "{$class}@onStartImpersonating");
$events->listen(LeaveImpersonation::class, "{$class}@onStopImpersonating");
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Vanguard\UserActivity;
use Illuminate\Contracts\Auth\Factory;
use Illuminate\Http\Request;
use Vanguard\User;
use Vanguard\UserActivity\Repositories\Activity\ActivityRepository;
class Logger
{
protected ?User $user = null;
public function __construct(
private readonly Request $request,
private readonly Factory $auth,
private readonly ActivityRepository $activities
) {
}
/**
* Log user action.
*/
public function log($description): Activity
{
return $this->activities->log([
'description' => $description,
'user_id' => $this->getUserId(),
'ip_address' => $this->request->ip(),
'user_agent' => $this->getUserAgent(),
]);
}
/**
* Get id if the user for who we want to log this action.
* If user was manually set, then we will just return id of that user.
* If not, we will return the id of currently logged user.
*/
private function getUserId(): ?int
{
if ($this->user) {
return $this->user->id;
}
return $this->auth->guard()->id();
}
/**
* Get user agent from request headers.
*/
private function getUserAgent(): string
{
return substr((string) $this->request->header('User-Agent'), 0, 500);
}
public function setUser(?User $user): void
{
$this->user = $user;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Vanguard\UserActivity\Repositories\Activity;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as BaseCollection;
use Vanguard\UserActivity\Activity;
interface ActivityRepository
{
/**
* Log user activity.
*
* @param $data array Array with following fields:
* description (string) - Description of user activity.
* user_id (int) - User unique identifier.
* ip_address (string) - Ip address from which user is accessing the website.
* user_agent (string) - User's browser info.
* @return mixed
*/
public function log(array $data): Activity;
/**
* Paginate activities for user.
*/
public function paginateActivitiesForUser(
int $userId,
int $perPage = 20,
?string $search = null
): LengthAwarePaginator;
/**
* Get specified number of latest user activity logs.
*
* @return Collection<Activity>
*/
public function getLatestActivitiesForUser(int $userId, int $activitiesCount = 10): Collection;
/**
* Paginate all activity records.
*/
public function paginateActivities(int $perPage = 20, ?string $search = null): LengthAwarePaginator;
/**
* Get count of user activities per day for given period of time.
*/
public function userActivityForPeriod(int $userId, Carbon $from, Carbon $to): BaseCollection;
}
@@ -0,0 +1,98 @@
<?php
namespace Vanguard\UserActivity\Repositories\Activity;
use Carbon\Carbon;
use DB;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection as BaseCollection;
use Vanguard\UserActivity\Activity;
class EloquentActivity implements ActivityRepository
{
/**
* {@inheritdoc}
*/
public function log($data): Activity
{
return Activity::create($data);
}
/**
* {@inheritdoc}
*/
public function paginateActivitiesForUser(
int $userId,
int $perPage = 20,
?string $search = null
): LengthAwarePaginator {
$query = Activity::where('user_id', $userId);
return $this->paginateAndFilterResults($perPage, $search, $query);
}
/**
* {@inheritdoc}
*/
public function getLatestActivitiesForUser(int $userId, int $activitiesCount = 10): Collection
{
return Activity::where('user_id', $userId)
->orderBy('created_at', 'DESC')
->limit($activitiesCount)
->get();
}
/**
* {@inheritdoc}
*/
public function paginateActivities(int $perPage = 20, ?string $search = null): LengthAwarePaginator
{
$query = Activity::with('user');
return $this->paginateAndFilterResults($perPage, $search, $query);
}
private function paginateAndFilterResults($perPage, $search, $query): LengthAwarePaginator
{
if ($search) {
$query->where('description', 'LIKE', "%$search%");
}
$result = $query->orderBy('created_at', 'DESC')
->paginate($perPage);
if ($search) {
$result->appends(['search' => $search]);
}
return $result;
}
/**
* {@inheritdoc}
*/
public function userActivityForPeriod($userId, Carbon $from, Carbon $to): BaseCollection
{
$result = Activity::select([
DB::raw('DATE(created_at) as day'),
DB::raw('count(id) as count'),
])
->where('user_id', $userId)
->whereBetween('created_at', [$from, $to])
->groupBy('day')
->orderBy('day', 'ASC')
->pluck('count', 'day');
while (! $from->isSameDay($to)) {
if (! $result->has($from->toDateString())) {
$result->put($from->toDateString(), 0);
}
$from->addDay();
}
return $result->sortBy(function ($value, $key) {
return strtotime($key);
});
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace Vanguard\UserActivity;
use Event;
use Route;
use Vanguard\Plugins\Plugin;
use Vanguard\Support\Sidebar\Item;
use Vanguard\UserActivity\Http\View\Composers\ShowUserComposer;
use Vanguard\UserActivity\Listeners\PermissionEventsSubscriber;
use Vanguard\UserActivity\Listeners\RoleEventsSubscriber;
use Vanguard\UserActivity\Listeners\UserEventsSubscriber;
use Vanguard\UserActivity\Repositories\Activity\ActivityRepository;
use Vanguard\UserActivity\Repositories\Activity\EloquentActivity;
use View;
class UserActivity extends Plugin
{
/**
* {@inheritDoc}
*/
public function sidebar(): Item
{
return Item::create(__('Activity Log'))
->route('activity.index')
->icon('fas fa-server')
->active('activity*')
->permissions('users.activity');
}
/**
* Register services.
*/
public function register(): void
{
$this->app->singleton(ActivityRepository::class, EloquentActivity::class);
}
/**
* Bootstrap services.
*
*
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function boot(): void
{
$this->loadViewsFrom(__DIR__.'/../resources/views', 'user-activity');
$this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'user-activity');
$this->loadJsonTranslationsFrom(__DIR__.'/../resources/lang');
$this->publishes([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'migrations');
$this->app->booted(function () {
$this->mapWebRoutes();
if ($this->app['config']->get('auth.expose_api')) {
$this->mapApiRoutes();
}
});
$this->attachViewComposers();
$this->registerEventListeners();
}
/**
* Map web plugin related routes.
*/
protected function mapWebRoutes(): void
{
Route::group([
'namespace' => 'Vanguard\UserActivity\Http\Controllers\Web',
'middleware' => 'web',
], function () {
$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
});
}
/**
* Map API plugin related routes.
*/
protected function mapApiRoutes(): void
{
Route::group([
'namespace' => 'Vanguard\UserActivity\Http\Controllers\Api',
'middleware' => 'api',
'prefix' => 'api',
], function () {
$this->loadRoutesFrom(__DIR__.'/../routes/api.php');
});
}
/**
* Register event subscribers for the plugin.
*/
private function registerEventListeners(): void
{
Event::subscribe(PermissionEventsSubscriber::class);
Event::subscribe(RoleEventsSubscriber::class);
Event::subscribe(UserEventsSubscriber::class);
}
/**
* Attach view composers to add necessary data to the view.
*/
private function attachViewComposers(): void
{
View::composer('user.view', ShowUserComposer::class);
}
}
@@ -0,0 +1,54 @@
<?php
namespace Vanguard\UserActivity\Widgets;
use Auth;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Vanguard\Plugins\Widget;
use Vanguard\User;
use Vanguard\UserActivity\Repositories\Activity\ActivityRepository;
class ActivityWidget extends Widget
{
/**
* {@inheritdoc}
*/
public ?string $width = '12';
private ?array $userActivity = null;
public function __construct(private readonly ActivityRepository $activities)
{
$this->permissions(function (User $user) {
return $user->hasRole('User');
});
}
public function render(): View
{
return view('user-activity::widgets.user-activity', [
'activities' => $this->getActivity(),
]);
}
public function scripts(): View
{
return view('user-activity::widgets.user-activity-scripts', [
'activities' => $this->getActivity(),
]);
}
private function getActivity(): array
{
if ($this->userActivity) {
return $this->userActivity;
}
return $this->userActivity = $this->activities->userActivityForPeriod(
Auth::user()->id,
Carbon::now()->subWeeks(2),
Carbon::now()
)->toArray();
}
}
@@ -0,0 +1,134 @@
<?php
namespace Vanguard\UserActivity\Tests\Feature\Api;
use Facades\Tests\Setup\UserFactory;
use Tests\Feature\ApiTestCase;
use Vanguard\User;
use Vanguard\UserActivity\Activity;
use Vanguard\UserActivity\Http\Resources\ActivityResource;
class ActivityTest extends ApiTestCase
{
/** @test */
public function unauthenticated()
{
$this->getJson('/api/activity')->assertStatus(401);
}
/** @test */
public function get_activities_without_permission()
{
$user = User::factory()->create();
$this->actingAs($user, self::API_GUARD)
->getJson('/api/activity')
->assertStatus(403);
}
/** @test */
public function paginate_activities()
{
$user = $this->getUser();
$user2 = User::factory()->create();
$activities = Activity::factory()->times(25)->create(['user_id' => $user->id]);
Activity::factory()->times(10)->create(['user_id' => $user2->id]);
$response = $this->actingAs($user, self::API_GUARD)->getJson('/api/activity');
$transformed = ActivityResource::collection($activities->take(20))->resolve();
$this->assertEquals($response->json('data'), $transformed);
$response->assertJson([
'meta' => [
'current_page' => 1,
'from' => 1,
'to' => 20,
'last_page' => 2,
'path' => url('api/activity'),
'total' => 35,
'per_page' => 20,
],
]);
}
/** @test */
public function paginate_activities_with_search_param()
{
$user = $this->getUser();
$set1 = Activity::factory()->times(10)->create([
'user_id' => $user->id,
'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
]);
$set2 = Activity::factory()->times(5)->create([
'user_id' => $user->id,
'description' => 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris...',
]);
$transformed = ActivityResource::collection($set2)->resolve();
$response = $this->actingAs($user, self::API_GUARD)
->getJson('/api/activity?filter[description]=minim&per_page=10&sort=created_at')
->assertOk();
$this->assertEquals($response->json('data'), $transformed);
$response->assertJson([
'meta' => [
'current_page' => 1,
'from' => 1,
'to' => 5,
'last_page' => 1,
'total' => 5,
'per_page' => 10,
'path' => url('api/activity'),
],
]);
}
/** @test */
public function paginate_activities_with_more_records_per_page_than_allowed()
{
$this->actingAs($this->getUser(), self::API_GUARD)
->getJson('/api/activity?per_page=140')
->assertStatus(422);
}
/** @test */
public function paginate_activities_for_user()
{
$user = UserFactory::user()->withPermissions('users.activity')->create();
$this->be($user, self::API_GUARD);
$activities = Activity::factory()->times(25)->create(['user_id' => $user->id]);
$response = $this->getJson("/api/activity?filters[user]={$user->id}");
$transformed = ActivityResource::collection($activities->take(20))->resolve();
$this->assertEquals($response->json('data'), $transformed);
$response->assertJson([
'meta' => [
'current_page' => 1,
'from' => 1,
'to' => 20,
'last_page' => 2,
'path' => url('api/activity'),
'total' => 25,
'per_page' => 20,
],
]);
}
/**
* @return mixed
*/
private function getUser()
{
return UserFactory::user()->withPermissions('users.activity')->create();
}
}
@@ -0,0 +1,35 @@
<?php
namespace Vanguard\UserActivity\Tests\Feature\Api;
use Carbon\Carbon;
use Tests\Feature\ApiTestCase;
use Vanguard\User;
use Vanguard\UserActivity\Activity;
use Vanguard\UserActivity\Repositories\Activity\ActivityRepository;
class StatsTest extends ApiTestCase
{
/** @test */
public function non_admin_users_cannot_get_user_stats()
{
$user = User::factory()->create();
Carbon::setTestNow(Carbon::now()->subWeek());
Activity::factory()->times(5)->create(['user_id' => $user->id]);
Carbon::setTestNow(null);
Activity::factory()->times(5)->create(['user_id' => $user->id]);
$response = $this->actingAs($user, self::API_GUARD)->getJson('/api/stats/activity');
$expected = app(ActivityRepository::class)->userActivityForPeriod(
$user->id,
Carbon::now()->subWeek(2),
Carbon::now()
)->toArray();
$response->assertOk()
->assertJson($expected);
}
}
@@ -0,0 +1,72 @@
<?php
namespace Vanguard\UserActivity\Tests\Feature\Web;
use Carbon\Carbon;
use Facades\Tests\Setup\UserFactory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Vanguard\UserActivity\Logger;
class ActivityTest extends TestCase
{
use RefreshDatabase;
public $logger;
protected function setUp(): void
{
parent::setUp();
$this->logger = app(Logger::class);
$this->artisan('db:seed');
}
/** @test */
public function display_all_activities()
{
$this->withoutMiddleware();
$user1 = UserFactory::create();
$user2 = UserFactory::create();
Carbon::setTestNow(Carbon::now());
$this->be($user1);
$this->logger->log('foo');
$this->be($user2);
$this->logger->log('bar');
$this->get('activity')
->assertSee('foo')
->assertSee('bar');
}
/** @test */
public function display_activities_for_a_specific_user()
{
$user = UserFactory::admin()->create();
$this->be($user);
$this->logger->log('foo');
$this->get("activity/user/{$user->id}/log")
->assertSee('foo');
}
/** @test */
public function search_activities()
{
$this->withoutMiddleware();
$user = UserFactory::create();
$this->be($user);
$this->logger->log('foo');
$this->logger->log('barrr');
$this->get('activity?search=foo')
->assertSee('foo')
->assertDontSee('barrr');
}
}
@@ -0,0 +1,31 @@
<?php
namespace Vanguard\UserActivity\Tests\Unit\Listeners;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Vanguard\User;
abstract class ListenerTestCase extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->be($this->user);
}
protected function assertMessageLogged($msg, $user = null): void
{
$this->assertDatabaseHas('user_activity', [
'user_id' => $user ? $user->id : $this->user->id,
'ip_address' => \Request::ip(),
'user_agent' => \Request::header('User-agent'),
'description' => $msg,
]);
}
}
@@ -0,0 +1,52 @@
<?php
namespace Vanguard\UserActivity\Tests\Unit\Listeners;
use Vanguard\Events\Permission\Created;
use Vanguard\Events\Permission\Deleted;
use Vanguard\Events\Permission\Updated;
// Manually require the base test case to avoid issues while running automated tests
require_once __DIR__.'/ListenerTestCase.php';
class PermissionEventsSubscriberTest extends \Vanguard\UserActivity\Tests\Unit\Listeners\ListenerTestCase
{
protected \Vanguard\Permission $perm;
protected function setUp(): void
{
parent::setUp();
$this->perm = \Vanguard\Permission::factory()->create();
}
protected function assertMessageLogged($msg, $user = null): void
{
$this->assertDatabaseHas('user_activity', [
'user_id' => $user ? $user->id : $this->user->id,
'ip_address' => \Request::ip(),
'user_agent' => \Request::header('User-agent'),
'description' => $msg,
]);
}
/** @test */
public function onCreate()
{
event(new Created($this->perm));
$this->assertMessageLogged("Created new permission called {$this->perm->display_name}.");
}
/** @test */
public function onUpdate()
{
event(new Updated($this->perm));
$this->assertMessageLogged("Updated the permission named {$this->perm->display_name}.");
}
/** @test */
public function onDelete()
{
event(new Deleted($this->perm));
$this->assertMessageLogged("Deleted permission named {$this->perm->display_name}.");
}
}
@@ -0,0 +1,47 @@
<?php
namespace Vanguard\UserActivity\Tests\Unit\Listeners;
use Vanguard\Events\Role\Created;
use Vanguard\Events\Role\Deleted;
use Vanguard\Events\Role\PermissionsUpdated;
use Vanguard\Events\Role\Updated;
class RoleEventsSubscriberTest extends ListenerTestCase
{
protected \Vanguard\Role $role;
protected function setUp(): void
{
parent::setUp();
$this->role = \Vanguard\Role::factory()->create();
}
/** @test */
public function onCreate()
{
event(new Created($this->role));
$this->assertMessageLogged("Created new role called {$this->role->display_name}.");
}
/** @test */
public function onUpdate()
{
event(new Updated($this->role));
$this->assertMessageLogged("Updated role with name {$this->role->display_name}.");
}
/** @test */
public function onDelete()
{
event(new Deleted($this->role));
$this->assertMessageLogged("Deleted role named {$this->role->display_name}.");
}
/** @test */
public function onPermissionsUpdate()
{
event(new PermissionsUpdated());
$this->assertMessageLogged('Updated role permissions.');
}
}
@@ -0,0 +1,200 @@
<?php
namespace Vanguard\UserActivity\Tests\Unit\Listeners;
use Tests\UpdatesSettings;
class UserEventsSubscriberTest extends ListenerTestCase
{
use UpdatesSettings;
protected \Vanguard\User $theUser;
protected function setUp(): void
{
parent::setUp();
$this->theUser = \Vanguard\User::factory()->create();
}
/** @test */
public function onLogin()
{
event(new \Vanguard\Events\User\LoggedIn);
$this->assertMessageLogged('Logged in.');
}
/** @test */
public function onLogout()
{
event(new \Vanguard\Events\User\LoggedOut());
$this->assertMessageLogged('Logged out.');
}
/** @test */
public function onRegister()
{
$this->setSettings([
'reg_enabled' => true,
'reg_email_confirmation' => true,
]);
$user = \Vanguard\User::factory()->create();
event(new \Illuminate\Auth\Events\Registered($user));
$this->assertMessageLogged('Created an account.', $user);
}
/** @test */
public function onAvatarChange()
{
event(new \Vanguard\Events\User\ChangedAvatar);
$this->assertMessageLogged('Updated profile avatar.');
}
/** @test */
public function onProfileDetailsUpdate()
{
event(new \Vanguard\Events\User\UpdatedProfileDetails);
$this->assertMessageLogged('Updated profile details.');
}
/** @test */
public function onDelete()
{
event(new \Vanguard\Events\User\Deleted($this->theUser));
$message = sprintf(
'Deleted user %s.',
$this->theUser->present()->nameOrEmail
);
$this->assertMessageLogged($message);
}
/** @test */
public function onBan()
{
event(new \Vanguard\Events\User\Banned($this->theUser));
$message = sprintf(
'Banned user %s.',
$this->theUser->present()->nameOrEmail
);
$this->assertMessageLogged($message);
}
/** @test */
public function onUpdateByAdmin()
{
event(new \Vanguard\Events\User\UpdatedByAdmin($this->theUser));
$message = sprintf(
'Updated profile details for %s.',
$this->theUser->present()->nameOrEmail
);
$this->assertMessageLogged($message);
}
/** @test */
public function onCreate()
{
event(new \Vanguard\Events\User\Created($this->theUser));
$message = sprintf(
'Created an account for user %s.',
$this->theUser->present()->nameOrEmail
);
$this->assertMessageLogged($message);
}
/** @test */
public function onSettingsUpdate()
{
event(new \Vanguard\Events\Settings\Updated);
$this->assertMessageLogged('Updated website settings.');
}
/** @test */
public function onTwoFactorEnable()
{
event(new \Vanguard\Events\User\TwoFactorEnabled);
$this->assertMessageLogged('Enabled Two-Factor Authentication.');
}
/** @test */
public function onTwoFactorDisable()
{
event(new \Vanguard\Events\User\TwoFactorDisabled);
$this->assertMessageLogged('Disabled Two-Factor Authentication.');
}
/** @test */
public function onTwoFactorEnabledByAdmin()
{
event(new \Vanguard\Events\User\TwoFactorEnabledByAdmin($this->theUser));
$message = sprintf(
'Enabled Two-Factor Authentication for user %s.',
$this->theUser->present()->nameOrEmail
);
$this->assertMessageLogged($message);
}
/** @test */
public function onTwoFactorDisabledByAdmin()
{
event(new \Vanguard\Events\User\TwoFactorDisabledByAdmin($this->theUser));
$message = sprintf(
'Disabled Two-Factor Authentication for user %s.',
$this->theUser->present()->nameOrEmail
);
$this->assertMessageLogged($message);
}
/** @test */
public function onPasswordResetEmailRequest()
{
event(new \Vanguard\Events\User\RequestedPasswordResetEmail($this->user));
$this->assertMessageLogged('Requested password reset email.');
}
/** @test */
public function onPasswordReset()
{
event(new \Illuminate\Auth\Events\PasswordReset($this->user));
$this->assertMessageLogged('Reseted password using "Forgot Password" option.');
}
/** @test */
public function onStartImpersonating()
{
$impersonated = \Vanguard\User::factory()->create([
'first_name' => 'John',
'last_name' => 'Doe',
]);
event(new \Lab404\Impersonate\Events\TakeImpersonation($this->user, $impersonated));
$this->assertMessageLogged("Started impersonating user John Doe (ID: {$impersonated->id})");
}
/** @test */
public function onStopImpersonating()
{
$impersonated = \Vanguard\User::factory()->create([
'first_name' => 'John',
'last_name' => 'Doe',
]);
event(new \Lab404\Impersonate\Events\LeaveImpersonation($this->user, $impersonated));
$this->assertMessageLogged("Stopped impersonating user John Doe (ID: {$impersonated->id})");
}
}
@@ -0,0 +1,136 @@
<?php
namespace Vanguard\UserActivity\Tests\Unit\Repositories\Activity;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Assert;
use Tests\TestCase;
use Vanguard\User;
use Vanguard\UserActivity\Activity;
use Vanguard\UserActivity\Repositories\Activity\EloquentActivity;
class EloquentActivityTest extends TestCase
{
use RefreshDatabase;
/**
* @var EloquentActivity
*/
protected $repo;
protected function setUp(): void
{
parent::setUp();
$this->repo = app(EloquentActivity::class);
}
/** @test */
public function log()
{
$user = User::factory()->create();
Carbon::setTestNow(Carbon::now());
$data = [
'user_id' => $user->id,
'ip_address' => '123.456.789.012',
'user_agent' => 'foo',
'description' => 'descriptionnnn',
];
$this->repo->log($data);
$this->assertDatabaseHas('user_activity', $data);
}
/** @test */
public function paginate_activities_for_user()
{
$user = User::factory()->create();
$activities = Activity::factory()->times(10)->create(['user_id' => $user->id]);
$result = $this->repo->paginateActivitiesForUser($user->id, 6)->toArray();
$this->assertEquals(6, count($result['data']));
$this->assertEquals(10, $result['total']);
$this->assertEquals($activities[0]->toArray(), $result['data'][0]);
$this->assertEquals($activities[5]->toArray(), $result['data'][5]);
}
/** @test */
public function latest_activities_for_user()
{
$user = User::factory()->create();
Carbon::setTestNow(Carbon::now()->subDay());
$activities1 = Activity::factory()->times(5)->create(['user_id' => $user->id]);
Carbon::setTestNow(null);
$activities2 = Activity::factory()->times(5)->create(['user_id' => $user->id]);
$result = $this->repo->getLatestActivitiesForUser($user->id, 6)->toArray();
$this->assertEquals(6, count($result));
$this->assertEquals($activities2[0]->toArray(), $result[0]);
$this->assertEquals($activities1[0]->toArray(), $result[5]);
}
/** @test */
public function paginate_activities()
{
$activities = Activity::factory()->times(10)->create();
$result = $this->repo->paginateActivities(6)->toArray();
$this->assertEquals(6, count($result['data']));
$this->assertEquals(10, $result['total']);
Assert::assertArraySubset($activities[0]->toArray(), $result['data'][0]);
Assert::assertArraySubset($activities[5]->toArray(), $result['data'][5]);
}
/** @test */
public function userActivityForPeriod()
{
$user = User::factory()->create();
$now = Carbon::now();
Carbon::setTestNow($now->copy()->subDays(15));
Activity::factory()->times(5)->create(['user_id' => $user->id]);
Carbon::setTestNow($now->copy()->subDays(11));
Activity::factory()->times(2)->create(['user_id' => $user->id]);
Carbon::setTestNow($now->copy()->subDays(5));
Activity::factory()->times(3)->create(['user_id' => $user->id]);
Carbon::setTestNow($now->copy()->subDays(2));
Activity::factory()->times(2)->create(['user_id' => $user->id]);
Carbon::setTestNow(null);
$result = $this->repo->userActivityForPeriod(
$user->id,
Carbon::now()->subWeeks(2),
Carbon::now()
);
$this->assertEquals($result->get(Carbon::now()->subDays(14)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(13)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(12)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(11)->toDateString()), 2);
$this->assertEquals($result->get(Carbon::now()->subDays(10)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(9)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(8)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(7)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(6)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(5)->toDateString()), 3);
$this->assertEquals($result->get(Carbon::now()->subDays(4)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(3)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->subDays(2)->toDateString()), 2);
$this->assertEquals($result->get(Carbon::now()->subDays(1)->toDateString()), 0);
$this->assertEquals($result->get(Carbon::now()->toDateString()), 0);
}
}
@@ -0,0 +1,14 @@
.DS_Store
/node_modules
/vendor
/.idea
/.vscode
/.vagrant
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
.env
.phpunit.result.cache
.php_cs.cache
composer.lock
+38
View File
@@ -0,0 +1,38 @@
{
"name": "vanguardapp/announcements",
"description": "Announcements plugin for Vanguard",
"keywords": [
"laravel",
"vanguard",
"announcements"
],
"license": "MIT",
"authors": [
{
"name": "Milos Stojanovic",
"email": "stojanovic.loshmi@gmail.com",
"homepage": "https://mstojanovic.net",
"role": "Developer"
}
],
"require": {
"php": "^8.0.2|^8.1|^8.2",
"vanguardapp/plugins": "^6.0",
"spatie/laravel-query-builder": "^5.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5.10"
},
"autoload": {
"psr-4": {
"Vanguard\\Announcements\\": "src/",
"Vanguard\\Announcements\\Database\\Seeders\\": "database/seeders/",
"Vanguard\\Announcements\\Database\\Factories\\": "database/factories/"
}
},
"autoload-dev": {
"psr-4": {
"Vanguard\\Announcements\\Tests\\": "tests/"
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace Vanguard\Announcements\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Vanguard\Announcements\Announcement;
class AnnouncementFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Announcement::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'title' => $this->faker->title,
'body' => $this->faker->paragraph(2),
'user_id' => function () {
return \Vanguard\User::factory()->create()->id;
},
];
}
}
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAnnouncementsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('announcements', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedInteger('user_id');
$table->string('title');
$table->text('body');
$table->timestamps();
$table->foreign('user_id')
->references('id')->on('users')
->onDelete('cascade');
});
Schema::table('users', function (Blueprint $table) {
$table->timestamp('announcements_last_read_at')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('announcements_last_read_at');
});
Schema::table('announcements', function (Blueprint $table) {
$table->dropForeign('announcements_user_id_foreign');
});
Schema::dropIfExists('announcements');
}
}
@@ -0,0 +1,34 @@
<?php
namespace Vanguard\Announcements\Database\Seeders;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Seeder;
use Vanguard\Permission;
use Vanguard\Role;
class AnnouncementsDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
$permission = Permission::create([
'name' => 'announcements.manage',
'display_name' => 'Manage Announcements',
'description' => '',
'removable' => false,
]);
Role::where('name', 'Admin')
->first()
->attachPermission($permission);
Model::reguard();
}
}
@@ -0,0 +1 @@
.nav-item.announcements a.nav-icon{font-size:1.3rem}.nav-item.announcements a.nav-icon:hover .activity-badge{-webkit-transform:scale(1.1);transform:scale(1.1)}.nav-item.announcements .activity-badge{width:13px;height:13px;background:#bf5329;border:2px solid #fff;border-radius:10px;position:absolute;right:-1px;-webkit-transition:all .15s;transition:all .15s;-webkit-transform:scale(.85);transform:scale(.85)}.nav-item.announcements .navbar-item{border-bottom:1px solid #e0ebf0}.nav-item.announcements .navbar-item:last-of-type{border-bottom:0}
@@ -0,0 +1 @@
!function(n){var e={};function t(o){if(e[o])return e[o].exports;var r=e[o]={i:o,l:!1,exports:{}};return n[o].call(r.exports,r,r.exports,t),r.l=!0,r.exports}t.m=n,t.c=e,t.d=function(n,e,o){t.o(n,e)||Object.defineProperty(n,e,{configurable:!1,enumerable:!0,get:o})},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,"a",e),e},t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.p="/",t(t.s=0)}({0:function(n,e,t){t("sV/x"),n.exports=t("xZZD")},"sV/x":function(n,e){$("#announcements-icon").click(function(){$.post(read_announcements_endpoint),$(".announcements .activity-badge").remove()})},xZZD:function(n,e){}});
@@ -0,0 +1,4 @@
{
"/js/announcements.js": "/js/announcements.js?id=e58568e3bf6dd42c45cd",
"/css/announcements.css": "/css/announcements.css?id=96053a4526b9aad79d23"
}
+11091
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"cross-env": "^5.1.4",
"laravel-mix": "^2.0"
}
}
+97
View File
@@ -0,0 +1,97 @@
📢 Announcements plugin for [Vanguard - Advanced PHP Login and User Management](https://vanguardapp.io)
system.
## Installation
This plugin requires Vanguard `4.0.0` or greater.
### Installation via Composer
To install the plugin first you will need to pull it via composer
by running the following command
```
composer require vanguardapp/announcements
```
The composer will install the plugin for you as well as it's dependencies.
The next step is to register the plugin by adding the
`\Vanguard\Announcements\Announcements::class`
to the list of Vanguard plugins inside the `VanguardServiceProvider`:
```php
protected function plugins()
{
return [
//...
\Vanguard\Announcements\Announcements::class,
];
}
```
As soon as your plugin is registered, you should publish the
plugins static assets and migrations by running the following command:
```
php artisan vendor:publish --provider="Vanguard\Announcements\Announcements" --tag="public" --tag="migrations"
```
And, as the last step of the installation, you will need to
run the following commands to make all the necessary database modifications:
```
php artisan migrate
php artisan db:seed --class="AnnouncementsDatabaseSeeder"
```
At this point the plugin will be fully installed and ready to go.
### Manual Installation
If you plan to make the modifications to the plugin and customize it to
fit your needs, it's much easier if you add it to your project manually.
To do so, you will need to download the ZIP archive from GitHub
by clicking the green "Clone or download" button and then choosing
the "Download ZIP" option from the dropdown.
Once you have the ZIP file on your computer, extract it to the
`plugins/Announcements` folder (you will need to create this folder
since it probably won't be present in your Vanguard installation).
Next step is to update your main `composer.json` file located in
Vanguard's root directory and add the following object to the `repositories`
array:
```
{
"type": "path",
"url": "./plugins/Announcements"
}
```
This will tell the composer that your plugin is located in `/plugins/Announcements`
directory and that it should be installed from there.
Now, add the following to the composer's `require` section
```
"vanguardapp/announcements": "*"
```
And run `composer update`.
Composer will now install the plugin from your local directory instead
of pulling it from GitHub, which means that you will be able to make
the changes to the plugin itself and customize it to fit your needs.
The rest of the process is the same as when the plugin is installed
by directly fetching it via composer from the GitHub repository, so you
will need to do all the same steps as above, which in short involves
updating the `VanguardServiceProvider` and running the commands to
publish plugin's static assets and to update the database.
## License
This plugin is an open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT).
@@ -0,0 +1,4 @@
$("#announcements-icon").click(function () {
$.post(read_announcements_endpoint);
$(".announcements .activity-badge").remove();
});
@@ -0,0 +1,31 @@
.nav-item.announcements {
a.nav-icon {
font-size: 1.3rem;
&:hover .activity-badge {
transform: scale(1.1);
}
}
.activity-badge {
width: 13px;
height: 13px;
background: #bf5329;
border: 2px solid #fff;
border-radius: 10px;
position: absolute;
right: -1px;
-webkit-transition: all 0.15s;
transition: all 0.15s;
-webkit-transform: scale(0.85);
transform: scale(0.85);
}
.navbar-item {
border-bottom: 1px solid #e0ebf0;
&:last-of-type {
border-bottom: 0;
}
}
}
@@ -0,0 +1,7 @@
<?php
return [
'created_announcement' => 'Erstellt eine Ankündigung #:id: :title',
'updated_announcement' => 'Aktualisierte Ankündigung #:id',
'deleted_announcement' => 'Gelöschte Ankündigung #:id',
];
+33
View File
@@ -0,0 +1,33 @@
{
"Announcement": "",
"Announcements": "",
"Create Announcement": "",
"Creator": "",
"Title": "",
"Created At": "",
"Action": "",
"Edit Announcement": "",
"Delete Announcement": "",
"Please Confirm": "",
"Are you sure that you want to delete this announcement?": "",
"Yes, delete it!": "",
"No announcements found.": "",
"No new announcements at the moment.": "",
"Edit": "",
"New Announcement": "",
"Create an Announcement": "",
"Update Announcement": "",
"Update": "",
"Create": "",
"What are you announcing?": "",
"Body": "",
"Describe your announcement using markdown...": "",
"E-Mail Notification": "",
"Send email notification about the announcement to all users.": "",
"Announcement Details": "",
"View All": "",
"Thanks": "",
"Announcement created successfully.": "",
"Announcement updated successfully.": "",
"Announcement deleted successfully.": ""
}
@@ -0,0 +1,7 @@
<?php
return [
'created_announcement' => 'Created an announcement #:id: :title',
'updated_announcement' => 'Updated announcement #:id',
'deleted_announcement' => 'Deleted announcement #:id',
];
+33
View File
@@ -0,0 +1,33 @@
{
"Announcement": "Annonce",
"Announcements": "Annonces",
"Create Announcement": "Créer une annonce",
"Creator": "Créateur",
"Title": "Titre",
"Created At": "Date de création",
"Action": "Action",
"Edit Announcement": "Modifier l'annonce",
"Delete Announcement": "Supprimer l'annonce",
"Please Confirm": "Confirmation",
"Are you sure that you want to delete this announcement?": "Êtes-vous bien sûr de vouloir supprimer cette annonce ?",
"Yes, delete it!": "Oui, supprimer",
"No announcements found.": "Aucune annonce à afficher",
"No new announcements at the moment.": "Aucune annonce pour le moment.",
"Edit": "Modifier",
"New Announcement": "Nouvelle annonce",
"Create an Announcement": "Créer une annonce",
"Update Announcement": "Mettre à jour l'annonce",
"Update": "Mettre à jour",
"Create": "Créer",
"What are you announcing?": "Que souhaitez-vous annoncer ?",
"Body": "Message",
"Describe your announcement using markdown...": "Vous pouvez utiliser Markdown dans le corps de votre annonce.",
"E-Mail Notification": "Notification par mail",
"Send email notification about the announcement to all users.": "Envoyer une notification par mail à tous les utilisateurs.",
"Announcement Details": "Détails de l'annonce",
"View All": "Liste complète",
"Thanks": "Merci",
"Announcement created successfully.": "L'annonce a bien été créée.",
"Announcement updated successfully.": "L'annonce a bien été mise à jour.",
"Announcement deleted successfully.": "L'annonce a bien été supprimée."
}
@@ -0,0 +1,7 @@
<?php
return [
'created_announcement' => 'Création de l\'annonce #:id: :title',
'updated_announcement' => 'Mise à jour de l\'annonce #:id',
'deleted_announcement' => 'Suppression de l\'annonce #:id',
];
+33
View File
@@ -0,0 +1,33 @@
{
"Announcement": "Objava",
"Announcements": "Objave",
"Create Announcement": "Kreiraj Objavu",
"Creator": "Autor",
"Title": "Naslov",
"Created At": "Datum Kreiranja",
"Action": "Akcija",
"Edit Announcement": "Izmeni Objavu",
"Delete Announcement": "Obriši Objavu",
"Please Confirm": "Molimo potvrdite",
"Are you sure that you want to delete this announcement?": "Da li ste sigurni da želite da obrišete ovu objavu?",
"Yes, delete it!": "Da, obriši!",
"No announcements found.": "Nema pronađenih objava.",
"No new announcements at the moment.": "Nema novih objava u ovom trenutku.",
"Edit": "Izmeni",
"New Announcement": "Nova Objava",
"Create an Announcement": "Kreiraj Objavu",
"Update Announcement": "Ažuriraj Objavu",
"Update": "Ažuriraj",
"Create": "Kreiraj",
"What are you announcing?": "Šta želite da objavite?",
"Body": "Opis",
"Describe your announcement using markdown...": "Napišite objavu koristeći markdown...",
"E-Mail Notification": "Email Obaveštenje",
"Send email notification about the announcement to all users.": "Pošalji email o ovom obaveštenju svim korisnicima.",
"Announcement Details": "Detalji Objave",
"View All": "Vidi sve",
"Thanks": "Hvala",
"Announcement created successfully.": "Objava je uspešno kreirana.",
"Announcement updated successfully.": "Objava je uspešno ažurirana.",
"Announcement deleted successfully.": "Objava je uspešno obrisana."
}
@@ -0,0 +1,7 @@
<?php
return [
'created_announcement' => 'Kreirao je objavu #:id: :title.',
'updated_announcement' => 'Ažurirao je objavu #:id.',
'deleted_announcement' => 'Obrisao je objavu #:id.',
];
@@ -0,0 +1,85 @@
@extends('layouts.app')
@section('page-title', $edit ? __('Update Announcement') : __('New Announcement'))
@section('page-heading', $edit ? __('Update Announcement') : __('New Announcement'))
@section('breadcrumbs')
<li class="breadcrumb-item">
<a href="{{ route('announcements.index') }}">@lang('Announcements')</a>
</li>
<li class="breadcrumb-item active">
{{ $edit ? __('Update') : __('Create') }}
</li>
@stop
@section('content')
@include('partials.messages')
@if ($edit)
{!! Form::open(['route' => ['announcements.update', $announcement], 'id' => 'announcement-form', 'method' => 'PUT']) !!}
@else
{!! Form::open(['route' => 'announcements.store', 'id' => 'announcement-form']) !!}
@endif
<div class="row">
<div class="col-md-6 my-4 mx-auto">
<div class="card">
<h6 class="card-header">
{{ $edit ? __('Update Announcement') : __('Create an Announcement') }}
</h6>
<div class="card-body">
<div class="form-group">
<label for="title">@lang('Title')</label>
<input type="text"
class="form-control input-solid"
id="title"
name="title"
placeholder="@lang('What are you announcing?')"
value="{{ $edit ? $announcement->title : '' }}">
</div>
<div class="form-group">
<label for="body">@lang('Body')</label>
<textarea
name="body"
class="form-control input-solid"
rows="10"
id="body"
placeholder="@lang('Describe your announcement using markdown...')"
>{{ $edit ? $announcement->body : '' }}</textarea>
</div>
@if (! $edit)
<div class="form-group mt-4">
<div class="custom-control custom-checkbox">
<input type="checkbox"
class="custom-control-input"
name="email_notification"
id="email_notification"
value="1"/>
<label class="custom-control-label font-weight-normal" for="email_notification">
<span class="d-block">@lang('E-Mail Notification')</span>
<small>@lang('Send email notification about the announcement to all users.')</small>
</label>
</div>
</div>
@endif
</div>
</div>
<div class="row">
<div class="col-md-12">
<button type="submit" class="btn btn-primary">
{{ $edit ? __('Update Announcement') : __('Create Announcement') }}
</button>
</div>
</div>
</div>
</div>
{!! Form::close() !!}
@stop
@section('scripts')
{!! JsValidator::formRequest(\Vanguard\Announcements\Http\Requests\AnnouncementRequest::class, '#announcement-form') !!}
@stop
@@ -0,0 +1,101 @@
@extends('layouts.app')
@section('page-title', __('Announcements'))
@section('page-heading', __('Announcements'))
@section('breadcrumbs')
<li class="breadcrumb-item active">
@lang('Announcements')
</li>
@stop
@section('content')
@include('partials.messages')
<div class="d-flex mb-4">
<a href="{{ route('announcements.create') }}" class="btn btn-primary btn-rounded ml-auto">
<i class="fas fa-plus mr-2"></i>
@lang('Create Announcement')
</a>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-borderless table-striped">
<thead>
<tr>
<th></th>
<th class="min-width-150">@lang('Creator')</th>
<th class="min-width-150">@lang('Title')</th>
<th class="min-width-150">@lang('Created At')</th>
<th class="text-center min-width-150">@lang('Action')</th>
</tr>
</thead>
<tbody>
@if (count($announcements))
@foreach ($announcements as $announcement)
<tr>
<td style="width: 40px;">
<a href="{{ route('users.show', $announcement->creator) }}">
<img
class="rounded-circle img-responsive"
width="40"
src="{{ $announcement->creator->present()->avatar }}"
alt="{{ $announcement->creator->present()->name }}">
</a>
</td>
<td class="align-middle">
@permission('users.manage')
<a href="{{ route('users.show', $announcement->creator) }}">
{{ $announcement->creator->username ?: __('N/A') }}
</a>
@else
<span>{{ $announcement->creator->username ?: __('N/A') }}</span>
@endpermission
</td>
<td class="align-middle">
<a href="{{ route('announcements.show', $announcement) }}">
{{ \Illuminate\Support\Str::limit($announcement->title, 50) }}
</a>
</td>
<td class="align-middle">
{{ $announcement->created_at->format(config('app.date_format')) }}
</td>
<td class="text-center align-middle">
<a href="{{ route('announcements.edit', $announcement) }}"
class="btn btn-icon edit"
title="@lang('Edit Announcement')"
data-toggle="tooltip" data-placement="top">
<i class="fas fa-edit"></i>
</a>
<a href="{{ route('announcements.destroy', $announcement) }}"
class="btn btn-icon"
title="@lang('Delete Announcement')"
data-toggle="tooltip"
data-placement="top"
data-method="DELETE"
data-confirm-title="@lang('Please Confirm')"
data-confirm-text="@lang('Are you sure that you want to delete this announcement?')"
data-confirm-delete="@lang('Yes, delete it!')">
<i class="fas fa-trash"></i>
</a>
</td>
</tr>
@endforeach
@else
<tr>
<td colspan="7"><em>@lang('No announcements found.')</em></td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
</div>
{!! $announcements->render() !!}
@stop
@@ -0,0 +1,33 @@
@extends('layouts.app')
@section('page-title', __('Announcements'))
@section('page-heading', __('Announcements'))
@section('breadcrumbs')
<li class="breadcrumb-item active">
@lang('Announcements')
</li>
@stop
@section('content')
<div class="row">
<div class="col-md-6 mx-auto mb-4">
@foreach ($announcements as $announcement)
@include('announcements::partials.card')
@endforeach
@if (count($announcements) == 0)
<div class="card">
<div class="card-body">
<p class="lead text-center m-0">
@lang('No new announcements at the moment.')
</p>
</div>
</div>
@endif
{!! $announcements->render() !!}
</div>
</div>
@stop
@@ -0,0 +1,10 @@
@component('mail::message')
# {{ $announcement->title }}
{!! $announcement->body !!}
@lang('Thanks'),<br>
{{ config('app.name') }}
@endcomponent
@@ -0,0 +1,39 @@
<div class="card overflow-hidden shadow-lg">
<div class="card-body p-0">
<div class="p-4">
<h4 class="card-title mb-3">
{{ $announcement->title }}
</h4>
<div>
{{ $announcement->parsed_body }}
</div>
</div>
<div class="d-flex justify-content-between align-items-center px-4 py-3 bg-lighter">
<div class="d-flex align-items-center ">
<img
class="rounded-circle img-responsive mr-2"
width="40"
src="{{ $announcement->creator->present()->avatar }}"
alt="{{ $announcement->creator->present()->name }}">
<div>
<div class="line-height-1">
{{ $announcement->creator->present()->name }}
</div>
<div class="text-muted">
{{ $announcement->created_at->format('M d') }}
</div>
</div>
</div>
@permission('announcements.manage')
<div>
<a href="{{ route('announcements.edit', $announcement) }}"
class="btn btn-secondary btn-sm">
@lang('Edit')
</a>
</div>
@endpermission
</div>
</div>
</div>
@@ -0,0 +1,19 @@
<div class="d-flex align-items-start px-4 p-4 navbar-item">
<img src="{{ $announcement->creator->present()->avatar }}" width="50" height="50"
class="rounded-circle img-responsive mr-3">
<div class="w-100">
<div class="d-flex justify-content-between align-items-start">
<span class="font-weight-bold">
{{ $announcement->creator->present()->name }}
</span>
<span class="text-muted">
{{ $announcement->created_at->diffForHumans(null, true, true) }}
</span>
</div>
<a href="{{ route('announcements.show', $announcement) }}">
{{ $announcement->title }}
</a>
</div>
</div>
@@ -0,0 +1,38 @@
<li class="nav-item dropdown announcements d-flex align-items-center px-3" id="announcements-icon">
<a href="#"
class="text-gray-500 position-relative nav-icon"
id="announcementsDropdown"
role="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
@if (count($announcements) > 0 && $announcements->first()->wasReadBy(auth()->user()))
<i class="activity-badge"></i>
@endif
<i class="fas fa-bullhorn"></i>
</a>
<div class="dropdown-menu dropdown-menu-right position-absolute p-0 shadow-lg"
aria-labelledby="announcementsDropdown"
style="width: 380px; height: 350px; overflow-y: scroll; overflow-x: hidden;">
<div class="text-center p-4">
<h5 class="text-muted mt-2">
@lang('Announcements')
</h5>
@if (count($announcements) > 0)
<a href="{{ route('announcements.list') }}">
@lang('View All')
</a>
@endif
</div>
<div class="bg-lighter">
@if (count($announcements) > 0)
@foreach ($announcements as $announcement)
@include('announcements::partials.navbar.item')
@endforeach
@else
<p class="text-center">@lang('No new announcements at the moment.')</p>
@endif
</div>
</div>
</li>
@@ -0,0 +1,4 @@
<script>
var read_announcements_endpoint = "{{ route('announcements.read') }}";
</script>
<script src="{{ url("vendor/plugins/announcements/js/announcements.js") }}"></script>
@@ -0,0 +1,4 @@
<link media="all"
type="text/css"
rel="stylesheet"
href="{{ url(mix('/css/announcements.css', 'vendor/plugins/announcements')) }}">
@@ -0,0 +1,24 @@
@extends('layouts.app')
@section('page-title', __('Announcement') . ":" . $announcement->title)
@section('page-heading', __('Announcement Details'))
@section('breadcrumbs')
<li class="breadcrumb-item">
<a href="{{ route('announcements.list') }}">
@lang('Announcements')
</a>
</li>
<li class="breadcrumb-item active">
{{ __('Announcement Details') }}
</li>
@stop
@section('content')
<div class="row">
<div class="col-md-6 mx-auto mb-4">
@include('announcements::partials.card')
</div>
</div>
@stop
+21
View File
@@ -0,0 +1,21 @@
<?php
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application.
| Enjoy building your API!
|
*/
Route::group(['middleware' => 'auth'], function () {
Route::apiResource('/announcements', 'AnnouncementsController')
->except('show')
->names('announcements.api');
Route::get('announcements/{announcementId}', 'AnnouncementsController@show');
Route::post('announcements/read', 'ReadAnnouncementsController@index');
});
+21
View File
@@ -0,0 +1,21 @@
<?php
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application.
| Now create something great!
|
*/
Route::group(['middleware' => ['auth', 'verified']], function () {
Route::get('announcements/list', 'AnnouncementListController@index')
->name('announcements.list');
Route::post('announcements/read', 'ReadAnnouncementsController@index')
->name('announcements.read');
Route::resource('announcements', 'AnnouncementsController');
});
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Vanguard\Announcements;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Table\TableExtension;
use Vanguard\Announcements\Database\Factories\AnnouncementFactory;
use Vanguard\User;
/**
* @property int $id
* @property string $title
* @property string $body
* @property Carbon $created_at
* @property Carbon $deleted_at
*/
class Announcement extends Model
{
use HasFactory;
protected $table = 'announcements';
protected $guarded = [];
public function creator(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function wasReadBy(User $user): bool
{
return $user->announcements_last_read_at < $this->created_at;
}
public function getParsedBodyAttribute(): HtmlString
{
$environment = Environment::createCommonMarkEnvironment();
$environment->addExtension(new TableExtension);
$converter = new CommonMarkConverter([
'html_input' => 'escape',
'allow_unsafe_links' => false,
], $environment);
return new HtmlString(
$converter->convertToHtml($this->attributes['body'])
);
}
/**
* {@inheritDoc}
*/
protected static function newFactory(): AnnouncementFactory
{
return new AnnouncementFactory;
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
namespace Vanguard\Announcements;
use Event;
use Route;
use Vanguard\Announcements\Events\EmailNotificationRequested;
use Vanguard\Announcements\Hooks\NavbarItemsHook;
use Vanguard\Announcements\Hooks\ScriptsHook;
use Vanguard\Announcements\Hooks\StylesHook;
use Vanguard\Announcements\Listeners\ActivityLogSubscriber;
use Vanguard\Announcements\Listeners\SendEmailNotification;
use Vanguard\Announcements\Repositories\AnnouncementsRepository;
use Vanguard\Announcements\Repositories\EloquentAnnouncements;
use Vanguard\Plugins\Plugin;
use Vanguard\Plugins\Vanguard;
use Vanguard\Support\Sidebar\Item;
class Announcements extends Plugin
{
/**
* A sidebar item for the plugin.
*/
public function sidebar(): ?Item
{
return Item::create(__('Announcements'))
->icon('fas fa-bullhorn')
->route('announcements.index')
->permissions('announcements.manage')
->active('announcements*');
}
/**
* Register plugin services.
*/
public function register(): void
{
$this->app->singleton(AnnouncementsRepository::class, EloquentAnnouncements::class);
}
/**
* Bootstrap services.
*
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function boot(): void
{
$this->registerViews();
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->loadViewsFrom(__DIR__.'/../resources/views', 'announcements');
$this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'announcements');
$this->loadJsonTranslationsFrom(__DIR__.'/../resources/lang');
$this->publishes([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'migrations');
$this->mapRoutes();
$this->registerHooks();
$this->registerEventListeners();
$this->publishAssets();
}
/**
* Register plugin views.
*/
protected function registerViews(): void
{
$viewsPath = __DIR__.'/../resources/views';
$this->publishes([
$viewsPath => resource_path('views/vendor/plugins/announcements'),
], 'views');
$this->loadViewsFrom($viewsPath, 'announcements');
}
/**
* Map all plugin related routes.
*/
protected function mapRoutes(): void
{
$this->mapWebRoutes();
if ($this->app['config']->get('auth.expose_api')) {
$this->mapApiRoutes();
}
}
/**
* Map web plugin related routes.
*/
protected function mapWebRoutes(): void
{
Route::group([
'namespace' => 'Vanguard\Announcements\Http\Controllers\Web',
'middleware' => 'web',
], function () {
$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
});
}
/**
* Map API plugin related routes.
*/
protected function mapApiRoutes(): void
{
Route::group([
'namespace' => 'Vanguard\Announcements\Http\Controllers\Api',
'middleware' => 'api',
'prefix' => 'api',
], function () {
$this->loadRoutesFrom(__DIR__.'/../routes/api.php');
});
}
/**
* Register plugin event listeners.
*/
private function registerEventListeners(): void
{
// Register activity log subscriber only if
// UserActivity plugin is installed.
if ($this->app->bound('Vanguard\UserActivity\Repositories\Activity\ActivityRepository')) {
Event::subscribe(ActivityLogSubscriber::class);
}
Event::listen(EmailNotificationRequested::class, SendEmailNotification::class);
}
/**
* Register all necessary view hooks for the plugin.
*/
private function registerHooks(): void
{
Vanguard::hook('navbar:items', NavbarItemsHook::class);
Vanguard::hook('app:styles', StylesHook::class);
Vanguard::hook('app:scripts', ScriptsHook::class);
}
/**
* Publish public assets.
*/
protected function publishAssets(): void
{
$this->publishes([
realpath(__DIR__.'/../dist') => $this->app['path.public'].'/vendor/plugins/announcements',
], 'public');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Vanguard\Announcements\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Vanguard\Announcements\Announcement;
class Created
{
use Dispatchable;
public function __construct(public Announcement $announcement, public $sendEmailNotification = false)
{
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Vanguard\Announcements\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Vanguard\Announcements\Announcement;
class Deleted
{
use Dispatchable;
public function __construct(public Announcement $announcement)
{
}
}
@@ -0,0 +1,15 @@
<?php
namespace Vanguard\Announcements\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Vanguard\Announcements\Announcement;
class EmailNotificationRequested
{
use Dispatchable;
public function __construct(public Announcement $announcement)
{
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Vanguard\Announcements\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Vanguard\Announcements\Announcement;
class Updated
{
use Dispatchable;
public function __construct(public Announcement $announcement)
{
}
}
@@ -0,0 +1,25 @@
<?php
namespace Vanguard\Announcements\Hooks;
use Illuminate\Contracts\View\View;
use Vanguard\Announcements\Repositories\AnnouncementsRepository;
use Vanguard\Plugins\Contracts\Hook;
class NavbarItemsHook implements Hook
{
public function __construct(private readonly AnnouncementsRepository $announcements)
{
}
/**
* Execute the hook action.
*/
public function handle(): View
{
$announcements = $this->announcements->latest(5);
$announcements->load('creator');
return view('announcements::partials.navbar.list', compact('announcements'));
}
}
@@ -0,0 +1,17 @@
<?php
namespace Vanguard\Announcements\Hooks;
use Illuminate\Contracts\View\View;
use Vanguard\Plugins\Contracts\Hook;
class ScriptsHook implements Hook
{
/**
* Execute the hook action.
*/
public function handle(): View
{
return view('announcements::partials.scripts');
}
}
@@ -0,0 +1,17 @@
<?php
namespace Vanguard\Announcements\Hooks;
use Illuminate\Contracts\View\View;
use Vanguard\Plugins\Contracts\Hook;
class StylesHook implements Hook
{
/**
* Execute the hook action.
*/
public function handle(): View
{
return view('announcements::partials.styles');
}
}
@@ -0,0 +1,108 @@
<?php
namespace Vanguard\Announcements\Http\Controllers\Api;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedInclude;
use Spatie\QueryBuilder\QueryBuilder;
use Vanguard\Announcements\Announcement;
use Vanguard\Announcements\Events\EmailNotificationRequested;
use Vanguard\Announcements\Http\Requests\AnnouncementRequest;
use Vanguard\Announcements\Http\Resources\AnnouncementResource;
use Vanguard\Announcements\Repositories\AnnouncementsRepository;
use Vanguard\Http\Controllers\Api\ApiController;
/**
* Class AnnouncementsController
*/
class AnnouncementsController extends ApiController
{
public function __construct(private readonly AnnouncementsRepository $announcements)
{
$this->middleware('permission:announcements.manage')->except('index', 'show');
}
/**
* Returns a paginated list of announcements.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->validate($request, ['per_page' => 'numeric|max:50']);
$announcements = QueryBuilder::for(Announcement::class)
->allowedIncludes([
AllowedInclude::relationship('user', 'creator'),
])
->allowedFilters([
AllowedFilter::partial('title'),
AllowedFilter::partial('body'),
AllowedFilter::exact('user', 'user_id'),
])
->allowedSorts('title', 'created_at')
->defaultSort('-created_at')
->paginate($request->per_page);
return AnnouncementResource::collection($announcements);
}
/**
* Stores the announcement inside the database.
*/
public function store(AnnouncementRequest $request): AnnouncementResource
{
$announcement = $this->announcements->createFor(
auth()->user(),
$request->title,
$request->body
);
if ($request->email_notification) {
EmailNotificationRequested::dispatch($announcement);
}
return new AnnouncementResource($announcement);
}
/**
* Returns a single announcement resource.
*/
public function show($announcementId): AnnouncementResource
{
$announcement = QueryBuilder::for(Announcement::where('id', $announcementId))
->allowedIncludes([
AllowedInclude::relationship('user', 'creator'),
])
->first();
return new AnnouncementResource($announcement);
}
/**
* Updates announcement details.
*/
public function update(Announcement $announcement, AnnouncementRequest $request): AnnouncementResource
{
$announcement = $this->announcements->update(
$announcement,
$request->title,
$request->body
);
return new AnnouncementResource($announcement);
}
/**
* Removes announcement from the system.
*/
public function destroy(Announcement $announcement): JsonResponse
{
$this->announcements->delete($announcement);
return $this->respondWithSuccess();
}
}
@@ -0,0 +1,23 @@
<?php
namespace Vanguard\Announcements\Http\Controllers\Api;
use Vanguard\Http\Controllers\Api\ApiController;
class ReadAnnouncementsController extends ApiController
{
/**
* Update the timestamp when announcements were last read
* by the currently authenticated user.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
auth()->user()->forceFill([
'announcements_last_read_at' => now(),
])->save();
return $this->respondWithSuccess();
}
}
@@ -0,0 +1,25 @@
<?php
namespace Vanguard\Announcements\Http\Controllers\Web;
use Illuminate\Contracts\View\View;
use Vanguard\Announcements\Repositories\AnnouncementsRepository;
use Vanguard\Http\Controllers\Controller;
class AnnouncementListController extends Controller
{
public function __construct(private readonly AnnouncementsRepository $announcements)
{
}
/**
* Displays the plugin index page.
*/
public function index(): View
{
$announcements = $this->announcements->paginate(7);
$announcements->load('creator');
return view('announcements::list', compact('announcements'));
}
}
@@ -0,0 +1,105 @@
<?php
namespace Vanguard\Announcements\Http\Controllers\Web;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Vanguard\Announcements\Announcement;
use Vanguard\Announcements\Events\EmailNotificationRequested;
use Vanguard\Announcements\Http\Requests\AnnouncementRequest;
use Vanguard\Announcements\Repositories\AnnouncementsRepository;
use Vanguard\Http\Controllers\Controller;
/**
* Class AnnouncementsController
*/
class AnnouncementsController extends Controller
{
public function __construct(private readonly AnnouncementsRepository $announcements)
{
$this->middleware('permission:announcements.manage')->except('show');
}
/**
* Displays the plugin index page.
*/
public function index(): View
{
$announcements = $this->announcements->paginate();
$announcements->load('creator');
return view('announcements::index', compact('announcements'));
}
/**
* Shows the create announcement form.
*/
public function create(): View
{
return view('announcements::add-edit', ['edit' => false]);
}
/**
* Stores the announcement inside the database.
*/
public function store(AnnouncementRequest $request): RedirectResponse
{
$announcement = $this->announcements->createFor(
auth()->user(),
$request->title,
$request->body
);
if ($request->email_notification) {
EmailNotificationRequested::dispatch($announcement);
}
return redirect()->route('announcements.index')
->withSuccess(__('Announcement created successfully.'));
}
/**
* Renders "view announcement" page.
*/
public function show(Announcement $announcement): View
{
return view('announcements::show', compact('announcement'));
}
/**
* Renders the form for editing the announcement.
*/
public function edit(Announcement $announcement): View
{
return view('announcements::add-edit', [
'edit' => true,
'announcement' => $announcement,
]);
}
/**
* Updates announcement details.
*/
public function update(Announcement $announcement, AnnouncementRequest $request): RedirectResponse
{
$this->announcements->update(
$announcement,
$request->title,
$request->body
);
return redirect()->route('announcements.index')
->withSuccess(__('Announcement updated successfully.'));
}
/**
* Removes announcement from the system.
*/
public function destroy(Announcement $announcement): RedirectResponse
{
$this->announcements->delete($announcement);
return redirect()->route('announcements.index')
->withSuccess(__('Announcement deleted successfully.'));
}
}
@@ -0,0 +1,19 @@
<?php
namespace Vanguard\Announcements\Http\Controllers\Web;
use Vanguard\Http\Controllers\Controller;
class ReadAnnouncementsController extends Controller
{
/**
* Update the timestamp when announcements were last read
* by the currently authenticated user.
*/
public function index(): void
{
auth()->user()->forceFill([
'announcements_last_read_at' => now(),
])->save();
}
}
@@ -0,0 +1,20 @@
<?php
namespace Vanguard\Announcements\Http\Requests;
use Vanguard\Http\Requests\Request;
class AnnouncementRequest extends Request
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'title' => 'required|max:150',
'body' => 'required|max:1500',
'email_notifications' => 'boolean',
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace Vanguard\Announcements\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Vanguard\Http\Resources\UserResource;
class AnnouncementResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
*/
public function toArray($request): array
{
return [
'id' => (int) $this->id,
'user_id' => (int) $this->user_id,
'title' => $this->title,
'body' => $this->body,
'parsed_body' => (string) $this->parsed_body,
'created_at' => (string) $this->created_at,
'updated_at' => (string) $this->updated_at,
'user' => new UserResource($this->whenLoaded('creator')),
];
}
}
@@ -0,0 +1,51 @@
<?php
namespace Vanguard\Announcements\Listeners;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Str;
use Vanguard\Announcements\Events\Created;
use Vanguard\Announcements\Events\Deleted;
use Vanguard\Announcements\Events\Updated;
use Vanguard\UserActivity\Logger;
class ActivityLogSubscriber
{
public function __construct(private Logger $logger)
{
}
public function onCreate(Created $event): void
{
$this->logger->log(__('announcements::log.created_announcement', [
'id' => $event->announcement->id,
'title' => Str::limit($event->announcement->title, 50),
]));
}
public function onUpdate(Updated $event): void
{
$this->logger->log(__('announcements::log.created_announcement', [
'id' => $event->announcement->id,
]));
}
public function onDelete(Deleted $event): void
{
$this->logger->log(__('announcements::log.deleted_announcement', [
'id' => $event->announcement->id,
]));
}
/**
* Register the listeners for the subscriber.
*/
public function subscribe(Dispatcher $events): void
{
$class = self::class;
$events->listen(Created::class, "{$class}@onCreate");
$events->listen(Updated::class, "{$class}@onUpdate");
$events->listen(Deleted::class, "{$class}@onDelete");
}
}
@@ -0,0 +1,29 @@
<?php
namespace Vanguard\Announcements\Listeners;
use Mail;
use Vanguard\Announcements\Announcement;
use Vanguard\Announcements\Events\EmailNotificationRequested;
use Vanguard\Announcements\Mail\AnnouncementEmail;
use Vanguard\User;
class SendEmailNotification
{
/**
* Handle the event.
*/
public function handle(EmailNotificationRequested $event): void
{
User::chunk(200, function ($users) use ($event) {
foreach ($users as $user) {
$this->sendEmailTo($user, $event->announcement);
}
});
}
private function sendEmailTo(User $user, Announcement $announcement): void
{
Mail::to($user)->send(new AnnouncementEmail($announcement));
}
}
@@ -0,0 +1,32 @@
<?php
namespace Vanguard\Announcements\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Vanguard\Announcements\Announcement;
class AnnouncementEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(public Announcement $announcement)
{
}
/**
* Build the message.
*/
public function build(): self
{
$subject = sprintf('[%s] %s', __('Announcement'), $this->announcement->title);
return $this->subject($subject)
->markdown('announcements::mail.notification');
}
}
@@ -0,0 +1,43 @@
<?php
namespace Vanguard\Announcements\Repositories;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Vanguard\Announcements\Announcement;
use Vanguard\User;
interface AnnouncementsRepository
{
/**
* Get latest announcements.
*
* @return Collection<Announcement>
*/
public function latest(int $count = 5): Collection;
/**
* Paginate announcements in descending order.
*/
public function paginate(int $perPage = 10): LengthAwarePaginator;
/**
* Create an announcement for user.
*/
public function createFor(User $user, string $title, string $body): Announcement;
/**
* Find announcement by ID.
*/
public function find(int $id): ?Announcement;
/**
* Update announcement.
*/
public function update(Announcement $announcement, string $title, string $body): Announcement;
/**
* Remove announcement from the system.
*/
public function delete(Announcement $announcement): bool;
}
@@ -0,0 +1,87 @@
<?php
namespace Vanguard\Announcements\Repositories;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Vanguard\Announcements\Announcement;
use Vanguard\Announcements\Events\Created;
use Vanguard\Announcements\Events\Deleted;
use Vanguard\Announcements\Events\Updated;
use Vanguard\User;
class EloquentAnnouncements implements AnnouncementsRepository
{
/**
* Get latest announcements.
*
* @return Collection<Announcement>
*/
public function latest(int $count = 5): Collection
{
return Announcement::latest()->take($count)->get();
}
/**
* Paginate announcements in descending order.
*/
public function paginate(int $perPage = 10): LengthAwarePaginator
{
return Announcement::latest()->paginate($perPage);
}
/**
* Create an announcement for user.
*/
public function createFor(User $user, string $title, string $body): Announcement
{
$announcement = Announcement::create([
'title' => $title,
'body' => $body,
'user_id' => $user->id,
]);
Created::dispatch($announcement);
return $announcement;
}
/**
* Find announcement by ID.
*/
public function find($id): ?Announcement
{
return Announcement::find($id);
}
/**
* Update announcement.
*/
public function update(Announcement $announcement, string $title, string $body): Announcement
{
$announcement->update([
'title' => $title,
'body' => $body,
]);
Updated::dispatch($announcement);
return $announcement;
}
/**
* Remove announcement from the system.
*
* @throws \Exception
*/
public function delete(Announcement $announcement): bool
{
if ($announcement->delete()) {
Deleted::dispatch($announcement);
return true;
}
return false;
}
}
@@ -0,0 +1,299 @@
<?php
namespace Vanguard\Announcements\Tests\Feature\Api;
use Carbon\Carbon;
use Facades\Tests\Setup\UserFactory;
use Mail;
use Tests\Feature\ApiTestCase;
use Vanguard\Announcements\Announcement;
use Vanguard\Announcements\Database\Seeders\AnnouncementsDatabaseSeeder;
use Vanguard\Announcements\Http\Resources\AnnouncementResource;
use Vanguard\Announcements\Mail\AnnouncementEmail;
class AnnouncementsTest extends ApiTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->artisan('db:seed', ['--class' => AnnouncementsDatabaseSeeder::class]);
}
/** @test */
public function guests_cannot_paginate_announcements()
{
$this->getJson('/api/announcements')->assertStatus(401);
}
/** @test */
public function any_authenticated_users_can_paginate_announcements()
{
$user = UserFactory::user()->create();
$announcements = Announcement::factory()->times(11)->create();
$response = $this->actingAs($user, self::API_GUARD)
->getJson('/api/announcements?per_page=10')
->assertOk();
$transformed = AnnouncementResource::collection($announcements->take(10))->resolve();
$this->assertEquals($response->json('data'), $transformed);
$response->assertJson([
'meta' => [
'current_page' => 1,
'from' => 1,
'to' => 10,
'last_page' => 2,
'path' => url('api/announcements'),
'total' => 11,
'per_page' => 10,
],
]);
}
/** @test */
public function paginate_announcements_with_more_records_per_page_than_allowed()
{
$this->actingAs($this->validUser(), self::API_GUARD)
->getJson('/api/announcements?per_page=140')
->assertStatus(422);
}
/** @test */
public function guests_cannot_create_announcements()
{
$this->postJson('/api/announcements', $this->validParams())
->assertUnauthorized();
$this->assertEquals(0, Announcement::count());
}
/** @test */
public function users_without_appropriate_permission_cannot_create_announcements()
{
$user = UserFactory::user()->create();
$this->actingAs($user, self::API_GUARD)
->postJson('/api/announcements', $this->validParams())
->assertForbidden();
$this->assertEquals(0, Announcement::count());
}
/** @test */
public function users_with_appropriate_permission_can_create_announcements()
{
$user = $this->validUser();
$data = $this->validParams();
$response = $this->actingAs($user, self::API_GUARD)
->postJson('/api/announcements', $data)
->assertStatus(201);
$announcement = Announcement::first();
$response->assertExactJson([
'data' => (new AnnouncementResource($announcement))->resolve(),
]);
$this->assertEquals($user->id, $announcement->user_id);
$this->assertEquals($data['title'], $announcement->title);
$this->assertEquals($data['body'], $announcement->body);
}
/** @test */
public function an_email_notification_can_be_triggered_when_an_announcement_is_created()
{
Mail::fake();
$data = $this->validParams(['email_notification' => true]);
$this->actingAs($this->validUser(), self::API_GUARD)
->postJson('/api/announcements', $data)
->assertStatus(201);
$announcement = Announcement::first();
Mail::assertQueued(AnnouncementEmail::class, function ($mail) use ($announcement) {
return $mail->announcement->id === $announcement->id;
});
}
/** @test */
public function title_field_is_required_when_creating_an_announcement()
{
$data = $this->validParams(['title' => '']);
$this->actingAs($this->validUser(), self::API_GUARD)
->postJson('/api/announcements', $data)
->assertStatus(422)
->assertJsonValidationErrors('title');
$this->assertEquals(0, Announcement::count());
}
/** @test */
public function body_field_is_required_when_creating_an_announcement()
{
$data = $this->validParams(['body' => '']);
$this->actingAs($this->validUser(), self::API_GUARD)
->postJson('/api/announcements', $data)
->assertStatus(422)
->assertJsonValidationErrors('body');
$this->assertEquals(0, Announcement::count());
}
/** @test */
public function guests_cannot_view_an_announcement()
{
$announcement = Announcement::factory()->create();
$this->getJson("/api/announcements/{$announcement->id}")
->assertUnauthorized();
}
/** @test */
public function authenticated_users_can_view_any_announcement()
{
$user = UserFactory::user()->create();
$announcement = Announcement::factory()->create();
$this->actingAs($user, self::API_GUARD)
->getJson("/api/announcements/{$announcement->id}")
->assertOk()
->assertExactJson([
'data' => (new AnnouncementResource($announcement))->resolve(),
]);
}
/** @test */
public function guests_cannot_update_an_announcement()
{
$announcement = Announcement::factory()->create();
$this->putJson("/api/announcements/{$announcement->id}", $this->validParams())
->assertUnauthorized();
}
/** @test */
public function users_without_approprite_permission_cannot_update_an_announcement()
{
$user = UserFactory::user()->create();
$announcement = Announcement::factory()->create();
$this->actingAs($user, self::API_GUARD)
->putJson("/api/announcements/{$announcement->id}", $this->validParams())
->assertForbidden();
}
/** @test */
public function users_with_approprite_permission_can_update_an_announcement()
{
$user = $this->validUser();
$announcement = Announcement::factory()->create();
$this->actingAs($user, self::API_GUARD)
->putJson("/api/announcements/{$announcement->id}", $this->validParams())
->assertOk()
->assertExactJson([
'data' => (new AnnouncementResource($announcement->fresh()))->resolve(),
]);
}
/** @test */
public function title_field_is_required_when_updating_an_announcement()
{
$data = $this->validParams(['title' => '']);
$announcement = Announcement::factory()->create();
$this->actingAs($this->validUser(), self::API_GUARD)
->putJson("/api/announcements/{$announcement->id}", $data)
->assertStatus(422)
->assertJsonValidationErrors('title');
}
/** @test */
public function body_field_is_required_when_updating_an_announcement()
{
$data = $this->validParams(['body' => '']);
$announcement = Announcement::factory()->create();
$this->actingAs($this->validUser(), self::API_GUARD)
->putJson("/api/announcements/{$announcement->id}", $data)
->assertStatus(422)
->assertJsonValidationErrors('body');
}
/** @test */
public function guests_cannot_delete_an_announcement()
{
$announcement = Announcement::factory()->create();
$this->deleteJson("/api/announcements/{$announcement->id}")
->assertUnauthorized();
}
/** @test */
public function users_without_appropriate_permission_cannot_delete_an_announcement()
{
$user = UserFactory::user()->create();
$announcement = Announcement::factory()->create();
$this->actingAs($user, self::API_GUARD)
->deleteJson("/api/announcements/{$announcement->id}")
->assertForbidden();
}
/** @test */
public function users_with_appropriate_permission_can_delete_an_announcement()
{
$announcement = Announcement::factory()->create();
$this->actingAs($this->validUser(), self::API_GUARD)
->deleteJson("/api/announcements/{$announcement->id}")
->assertOk();
$this->assertNull($announcement->fresh());
}
/** @test */
public function user_announcements_can_be_marked_as_read()
{
$user = UserFactory::user()->create([
'announcements_last_read_at' => null,
]);
Carbon::setTestNow(now());
$this->actingAs($user, self::API_GUARD)
->post('/api/announcements/read');
$this->assertEquals(
now()->format('Y-m-d H:i:s'),
$user->fresh()->announcements_last_read_at
);
}
/**
* @return mixed
*/
private function validUser()
{
return UserFactory::user()->withPermissions('announcements.manage')->create();
}
/**
* @return array
*/
private function validParams(array $overrides = [])
{
return array_merge([
'title' => 'Foo Announcement',
'body' => 'This is the announcement body.',
'email_notification' => false,
], $overrides);
}
}
@@ -0,0 +1,361 @@
<?php
namespace Vanguard\Announcements\Tests\Feature\Web;
use Carbon\Carbon;
use Facades\Tests\Setup\UserFactory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Arr;
use Mail;
use Tests\TestCase;
use Vanguard\Announcements\Announcement;
use Vanguard\Announcements\Database\Seeders\AnnouncementsDatabaseSeeder;
use Vanguard\Announcements\Mail\AnnouncementEmail;
class AnnouncementsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->artisan('db:seed', ['--class' => 'RolesSeeder']);
$this->artisan('db:seed', ['--class' => 'PermissionsSeeder']);
$this->artisan('db:seed', ['--class' => 'CountriesSeeder']);
$this->artisan('db:seed', ['--class' => AnnouncementsDatabaseSeeder::class]);
}
/** @test */
public function guests_cannot_view_announcement_index_page()
{
$this->get('/announcements')->assertRedirect('login');
}
/** @test */
public function users_without_appropriate_permission_cannot_view_announcement_list()
{
$user = UserFactory::user()->create();
$this->actingAs($user)->get('/announcements')->assertForbidden();
}
/** @test */
public function users_with_appropriate_permission_can_view_the_announcement_list()
{
$user = UserFactory::user()->withPermissions('announcements.manage')->create();
Announcement::factory()->create([
'user_id' => $user->id,
'title' => 'Foo Announcement',
]);
$response = $this->actingAs($user)->get('/announcements')->assertOk();
$response->assertSee('Foo Announcement');
}
/** @test */
public function guests_cannot_create_announcements()
{
$this->post('/announcements', $this->validParams())->assertRedirect('login');
$this->assertEquals(0, Announcement::count());
}
/** @test */
public function users_without_appropriate_permission_cannot_create_announcements()
{
$user = UserFactory::user()->create();
$this->actingAs($user)->post('/announcements', $this->validParams())->assertForbidden();
$this->assertEquals(0, Announcement::count());
}
/** @test */
public function users_with_appropriate_permission_can_create_announcements()
{
Mail::fake();
$user = UserFactory::user()->withPermissions('announcements.manage')->create();
$data = $this->validParams();
$this->actingAs($user)->post('/announcements', $data)
->assertRedirect('/announcements');
$announcement = Announcement::first();
$this->assertEquals($data['title'], $announcement->title);
$this->assertEquals($data['body'], $announcement->body);
Mail::assertNothingSent();
}
/** @test */
public function title_field_is_required_when_creating_an_announcement()
{
$user = UserFactory::user()->withPermissions('announcements.manage')->create();
$data = $this->validParams(['title' => '']);
$this->actingAs($user)
->from('/announcements/create')
->post('/announcements', $data)
->assertRedirect('/announcements/create')
->assertSessionHasErrors('title');
}
/** @test */
public function body_field_is_required_when_creating_an_announcement()
{
$user = UserFactory::user()->withPermissions('announcements.manage')->create();
$data = $this->validParams(['body' => '']);
$this->actingAs($user)
->from('/announcements/create')
->post('/announcements', $data)
->assertRedirect('/announcements/create')
->assertSessionHasErrors('body');
}
/** @test */
public function an_email_notification_can_be_triggered_when_an_announcement_is_created()
{
Mail::fake();
$user = UserFactory::user()->withPermissions('announcements.manage')->create();
$data = $this->validParams(['email_notification' => '1']);
$this->actingAs($user)
->from('/announcements/create')
->post('/announcements', $data)
->assertRedirect('/announcements');
$announcement = Announcement::first();
Mail::assertQueued(AnnouncementEmail::class, function ($mail) use ($announcement) {
return $mail->announcement->id === $announcement->id;
});
}
/** @test */
public function guests_cannot_view_an_announcement()
{
$announcement = Announcement::factory()->create();
$this->get('/announcements/'.$announcement->id)
->assertRedirect('/login');
}
/** @test */
public function authenticated_users_can_see_an_announcement()
{
$this->withoutExceptionHandling();
$user = UserFactory::user()->create();
$admin = UserFactory::admin()->create();
$data = Arr::except($this->validParams(['user_id' => $admin->id]), 'email_notification');
$announcement = Announcement::factory()->create($data);
$this->actingAs($user)->get('/announcements/'.$announcement->id)
->assertOk()
->assertSee($data['title']);
$this->actingAs($admin)->get('/announcements/'.$announcement->id)
->assertOk()
->assertSee($data['title']);
}
/** @test */
public function guests_cannot_edit_announcements()
{
$announcement = Announcement::factory()->create();
$this->get('/announcements/'.$announcement->id)->assertRedirect('login');
$this->put('/announcements/'.$announcement->id, $this->validParams())->assertRedirect('login');
$this->assertEquals($announcement->title, $announcement->fresh()->title);
$this->assertEquals($announcement->body, $announcement->fresh()->body);
}
/** @test */
public function users_with_appropriate_permissions_can_edit_an_announcement()
{
$user = UserFactory::user()->withPermissions('announcements.manage')->create();
$announcement = Announcement::factory()->create();
$data = $this->validParams();
$this->actingAs($user)
->from("/announcements/{$announcement->id}/edit")
->put('/announcements/'.$announcement->id, $data)
->assertRedirect('/announcements')
->assertSessionDoesntHaveErrors();
$this->assertEquals($data['title'], $announcement->fresh()->title);
$this->assertEquals($data['body'], $announcement->fresh()->body);
}
/** @test */
public function title_field_is_required_when_updating_an_announcement()
{
$user = UserFactory::user()->withPermissions('announcements.manage')->create();
$announcement = Announcement::factory()->create();
$data = $this->validParams(['title' => '']);
$this->actingAs($user)
->from("/announcements/{$announcement->id}/edit")
->put('/announcements/'.$announcement->id, $data)
->assertRedirect("/announcements/{$announcement->id}/edit")
->assertSessionHasErrors('title');
}
/** @test */
public function body_field_is_required_when_updating_an_announcement()
{
$user = UserFactory::user()->withPermissions('announcements.manage')->create();
$announcement = Announcement::factory()->create();
$data = $this->validParams(['body' => '']);
$this->actingAs($user)
->from("/announcements/{$announcement->id}/edit")
->put('/announcements/'.$announcement->id, $data)
->assertRedirect("/announcements/{$announcement->id}/edit")
->assertSessionHasErrors('body');
}
/** @test */
public function guests_cannot_delete_an_announcement()
{
$announcement = Announcement::factory()->create();
$this->delete('/announcements/'.$announcement->id)->assertRedirect('login');
$this->assertNotNull($announcement->fresh());
}
/** @test */
public function users_without_appropriate_permission_cannot_delete_an_announcement()
{
$user = UserFactory::user()->create();
$announcement = Announcement::factory()->create();
$this->actingAs($user)
->delete('/announcements/'.$announcement->id)
->assertForbidden();
$this->assertNotNull($announcement->fresh());
}
/** @test */
public function users_with_appropriate_permission_can_delete_an_announcement()
{
$user = UserFactory::user()->withPermissions('announcements.manage')->create();
$announcement = Announcement::factory()->create();
$this->actingAs($user)
->delete('/announcements/'.$announcement->id)
->assertRedirect('/announcements');
$this->assertNull($announcement->fresh());
}
/** @test */
public function guests_cannot_view_announcement_list()
{
$this->get('/announcements/list')->assertRedirect('login');
}
/** @test */
public function any_authenticated_user_can_view_announcement_list()
{
$user = UserFactory::user()->create();
$announcementA = Announcement::factory()->create();
$announcementB = Announcement::factory()->create();
$this->actingAs($user)
->get('/announcements/list')
->assertSee($announcementA->title)
->assertSee($announcementA->parsed_body)
->assertSee($announcementB->title)
->assertSee($announcementB->parsed_body);
}
/** @test */
public function authenticated_users_can_see_the_announcements_header_section()
{
$user = UserFactory::user()->create();
$data = ['title' => 'some random announcement'];
Announcement::factory()->create($data);
$this->actingAs($user)
->get('/')
->assertSee('id="announcements-icon"', false)
->assertSee('id="announcementsDropdown"', false)
->assertSee($data['title']);
}
/** @test */
public function a_red_dot_indicator_is_displayed_if_user_has_unread_announcements()
{
Announcement::factory()->create([
'created_at' => now()->subMinutes(3),
]);
$userA = UserFactory::user()->create([
'announcements_last_read_at' => now(),
]);
$userB = UserFactory::user()->create([
'announcements_last_read_at' => now()->subMinutes(5),
]);
$this->actingAs($userA)
->get('/')
->assertDontSee('activity-badge');
$this->actingAs($userB)
->get('/')
->assertSee('activity-badge');
}
/** @test */
public function user_announcements_can_be_marked_as_read()
{
$user = UserFactory::user()->create([
'announcements_last_read_at' => null,
]);
Carbon::setTestNow(now());
$this->actingAs($user)
->post('/announcements/read');
$this->assertEquals(
now()->format('Y-m-d H:i:s'),
$user->fresh()->announcements_last_read_at
);
}
private function validParams(array $overrides = [])
{
return array_merge([
'title' => 'Foo Announcement',
'body' => 'This is the announcement body.',
'email_notification' => '0',
], $overrides);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Vanguard\Announcements\Tests\Unit;
use Tests\TestCase;
use Vanguard\Announcements\Announcement;
class AnnouncementTest extends TestCase
{
/** @test */
public function testParsedBody()
{
$announcement = new Announcement([
'title' => 'foo',
'body' => '# test',
]);
$this->assertEquals("<h1>test</h1>\n", (string) $announcement->parsed_body);
}
}
+10
View File
@@ -0,0 +1,10 @@
const mix = require('laravel-mix');
mix.setPublicPath('./dist/');
mix.js( __dirname + '/resources/assets/js/app.js', 'dist/js/announcements.js')
.sass( __dirname + '/resources/assets/sass/app.scss', 'dist/css/announcements.css');
if (mix.inProduction()) {
mix.version();
}

Some files were not shown because too many files have changed in this diff Show More