Started work on the recycle bin interface

This commit is contained in:
Dan Brown 2020-10-03 18:44:12 +01:00
parent 691027a522
commit 04197e393a
No known key found for this signature in database
GPG key ID: 46D9F943C24A2EF9
15 changed files with 259 additions and 31 deletions

View file

@ -5,7 +5,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class DeleteRecord extends Model
class Deletion extends Model
{
/**
@ -13,21 +13,21 @@ class DeleteRecord extends Model
*/
public function deletable(): MorphTo
{
return $this->morphTo();
return $this->morphTo('deletable')->withTrashed();
}
/**
* The the user that performed the deletion.
*/
public function deletedBy(): BelongsTo
public function deleter(): BelongsTo
{
return $this->belongsTo(User::class);
return $this->belongsTo(User::class, 'deleted_by');
}
/**
* Create a new deletion record for the provided entity.
*/
public static function createForEntity(Entity $entity): DeleteRecord
public static function createForEntity(Entity $entity): Deletion
{
$record = (new self())->forceFill([
'deleted_by' => user()->id,

View file

@ -204,9 +204,9 @@ class Entity extends Ownable
/**
* Get the related delete records for this entity.
*/
public function deleteRecords(): MorphMany
public function deletions(): MorphMany
{
return $this->morphMany(DeleteRecord::class, 'deletable');
return $this->morphMany(Deletion::class, 'deletable');
}
/**

View file

@ -57,6 +57,7 @@ class EntityProvider
/**
* Fetch all core entity types as an associated array
* with their basic names as the keys.
* @return [string => Entity]
*/
public function all(): array
{

View file

@ -3,8 +3,9 @@
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\DeleteRecord;
use BookStack\Entities\Deletion;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\HasCoverImage;
use BookStack\Entities\Page;
use BookStack\Exceptions\NotifyException;
@ -21,7 +22,7 @@ class TrashCan
*/
public function softDestroyShelf(Bookshelf $shelf)
{
DeleteRecord::createForEntity($shelf);
Deletion::createForEntity($shelf);
$shelf->delete();
}
@ -31,7 +32,7 @@ class TrashCan
*/
public function softDestroyBook(Book $book)
{
DeleteRecord::createForEntity($book);
Deletion::createForEntity($book);
foreach ($book->pages as $page) {
$this->softDestroyPage($page, false);
@ -51,7 +52,7 @@ class TrashCan
public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
{
if ($recordDelete) {
DeleteRecord::createForEntity($chapter);
Deletion::createForEntity($chapter);
}
if (count($chapter->pages) > 0) {
@ -70,7 +71,7 @@ class TrashCan
public function softDestroyPage(Page $page, bool $recordDelete = true)
{
if ($recordDelete) {
DeleteRecord::createForEntity($page);
Deletion::createForEntity($page);
}
// Check if set as custom homepage & remove setting if not used or throw error if active
@ -151,6 +152,59 @@ class TrashCan
$page->forceDelete();
}
/**
* Get the total counts of those that have been trashed
* but not yet fully deleted (In recycle bin).
*/
public function getTrashedCounts(): array
{
$provider = app(EntityProvider::class);
$counts = [];
/** @var Entity $instance */
foreach ($provider->all() as $key => $instance) {
$counts[$key] = $instance->newQuery()->onlyTrashed()->count();
}
return $counts;
}
/**
* Destroy all items that have pending deletions.
*/
public function destroyFromAllDeletions()
{
$deletions = Deletion::all();
foreach ($deletions as $deletion) {
// For each one we load in the relation since it may have already
// been deleted as part of another deletion in this loop.
$entity = $deletion->deletable()->first();
if ($entity) {
$this->destroyEntity($deletion->deletable);
}
$deletion->delete();
}
}
/**
* Destroy the given entity.
*/
protected function destroyEntity(Entity $entity)
{
if ($entity->isA('page')) {
return $this->destroyPage($entity);
}
if ($entity->isA('chapter')) {
return $this->destroyChapter($entity);
}
if ($entity->isA('book')) {
return $this->destroyBook($entity);
}
if ($entity->isA('shelf')) {
return $this->destroyShelf($entity);
}
}
/**
* Update entity relations to remove or update outstanding connections.
*/
@ -163,7 +217,7 @@ class TrashCan
$entity->comments()->delete();
$entity->jointPermissions()->delete();
$entity->searchTerms()->delete();
$entity->deleteRecords()->delete();
$entity->deletions()->delete();
if ($entity instanceof HasCoverImage && $entity->cover) {
$imageService = app()->make(ImageService::class);

View file

@ -14,7 +14,6 @@ class HomeController extends Controller
/**
* Display the homepage.
* @return Response
*/
public function index()
{
@ -22,9 +21,12 @@ class HomeController extends Controller
$draftPages = [];
if ($this->isSignedIn()) {
$draftPages = Page::visible()->where('draft', '=', true)
$draftPages = Page::visible()
->where('draft', '=', true)
->where('created_by', '=', user()->id)
->orderBy('updated_at', 'desc')->take(6)->get();
->orderBy('updated_at', 'desc')
->take(6)
->get();
}
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;

View file

@ -2,6 +2,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Notifications\TestEmail;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
@ -19,7 +20,13 @@ class MaintenanceController extends Controller
// Get application version
$version = trim(file_get_contents(base_path('version')));
return view('settings.maintenance', ['version' => $version]);
// Recycle bin details
$recycleStats = (new TrashCan())->getTrashedCounts();
return view('settings.maintenance', [
'version' => $version,
'recycleStats' => $recycleStats,
]);
}
/**

View file

@ -0,0 +1,35 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Entities\Deletion;
use BookStack\Entities\Managers\TrashCan;
use Illuminate\Http\Request;
class RecycleBinController extends Controller
{
/**
* Show the top-level listing for the recycle bin.
*/
public function index()
{
$this->checkPermission('settings-manage');
$this->checkPermission('restrictions-manage-all');
$deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10);
return view('settings.recycle-bin', [
'deletions' => $deletions,
]);
}
/**
* Empty out the recycle bin.
*/
public function empty()
{
$this->checkPermission('settings-manage');
$this->checkPermission('restrictions-manage-all');
(new TrashCan())->destroyFromAllDeletions();
return redirect('/settings/recycle-bin');
}
}

View file

@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateDeleteRecordsTable extends Migration
class CreateDeletionsTable extends Migration
{
/**
* Run the migrations.
@ -13,7 +13,7 @@ class CreateDeleteRecordsTable extends Migration
*/
public function up()
{
Schema::create('delete_records', function (Blueprint $table) {
Schema::create('deletions', function (Blueprint $table) {
$table->increments('id');
$table->integer('deleted_by');
$table->string('deletable_type', 100);
@ -33,6 +33,6 @@ class CreateDeleteRecordsTable extends Migration
*/
public function down()
{
Schema::dropIfExists('delete_records');
Schema::dropIfExists('deletions');
}
}

View file

@ -80,6 +80,18 @@ return [
'maint_send_test_email_mail_subject' => 'Test Email',
'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
'maint_recycle_bin_desc' => 'Items deleted remain in the recycle bin until it is emptied. Open the recycle bin to restore or permanently remove items.',
'maint_recycle_bin_open' => 'Open Recycle Bin',
// Recycle Bin
'recycle_bin' => 'Recycle Bin',
'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
'recycle_bin_deleted_item' => 'Deleted Item',
'recycle_bin_deleted_by' => 'Deleted By',
'recycle_bin_deleted_at' => 'Deletion Time',
'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
'recycle_bin_empty' => 'Empty Recycle Bin',
'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
// Audit Log
'audit' => 'Audit Log',

View file

@ -290,12 +290,12 @@ $btt-size: 40px;
}
}
table a.audit-log-user {
table.table .table-user-item {
display: grid;
grid-template-columns: 42px 1fr;
align-items: center;
}
table a.icon-list-item {
table.table .table-entity-item {
display: grid;
grid-template-columns: 36px 1fr;
align-items: center;

View file

@ -0,0 +1,12 @@
{{--
$user - User mode to display, Can be null.
$user_id - Id of user to show. Must be provided.
--}}
@if($user)
<a href="{{ $user->getEditUrl() }}" class="table-user-item">
<div><img class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
<div>{{ $user->name }}</div>
</a>
@else
[ID: {{ $user_id }}] {{ trans('common.deleted_user') }}
@endif

View file

@ -60,19 +60,12 @@
@foreach($activities as $activity)
<tr>
<td>
@if($activity->user)
<a href="{{ $activity->user->getEditUrl() }}" class="audit-log-user">
<div><img class="avatar block" src="{{ $activity->user->getAvatar(40)}}" alt="{{ $activity->user->name }}"></div>
<div>{{ $activity->user->name }}</div>
</a>
@else
[ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }}
@endif
@include('partials.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
</td>
<td>{{ $activity->key }}</td>
<td>
@if($activity->entity)
<a href="{{ $activity->entity->getUrl() }}" class="icon-list-item">
<a href="{{ $activity->entity->getUrl() }}" class="table-entity-item">
<span role="presentation" class="icon text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
<div class="text-{{ $activity->entity->getType() }}">
{{ $activity->entity->name }}

View file

@ -50,5 +50,23 @@
</div>
</div>
<div class="card content-wrap auto-height pb-xl">
<h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
<div class="grid half gap-xl">
<div>
<p class="small text-muted">{{ trans('settings.maint_recycle_bin_desc') }}</p>
<div class="grid half no-gap">
<p class="mb-xs text-bookshelf">@icon('bookshelf'){{ trans('entities.shelves') }}: {{ $recycleStats['bookshelf'] }}</p>
<p class="mb-xs text-book">@icon('book'){{ trans('entities.books') }}: {{ $recycleStats['book'] }}</p>
<p class="mb-xs text-chapter">@icon('chapter'){{ trans('entities.chapters') }}: {{ $recycleStats['chapter'] }}</p>
<p class="mb-xs text-page">@icon('page'){{ trans('entities.pages') }}: {{ $recycleStats['page'] }}</p>
</div>
</div>
<div>
<a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('settings.maint_recycle_bin_open') }}</a>
</div>
</div>
</div>
</div>
@stop

View file

@ -0,0 +1,90 @@
@extends('simple-layout')
@section('body')
<div class="container">
<div class="grid left-focus v-center no-row-gap">
<div class="py-m">
@include('settings.navbar', ['selected' => 'maintenance'])
</div>
</div>
<div class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
<div class="grid half left-focus">
<div>
<p class="text-muted">{{ trans('settings.recycle_bin_desc') }}</p>
</div>
<div class="text-right">
<div component="dropdown" class="dropdown-container">
<button refs="dropdown@toggle"
type="button"
class="button outline">{{ trans('settings.recycle_bin_empty') }} </button>
<div refs="dropdown@menu" class="dropdown-menu">
<p class="text-neg small px-m mb-xs">{{ trans('settings.recycle_bin_empty_confirm') }}</p>
<form action="{{ url('/settings/recycle-bin/empty') }}" method="POST">
{!! csrf_field() !!}
<button type="submit" class="text-primary small delete">{{ trans('common.confirm') }}</button>
</form>
</div>
</div>
</div>
</div>
<hr class="mt-l mb-s">
{!! $deletions->links() !!}
<table class="table">
<tr>
<th>{{ trans('settings.recycle_bin_deleted_item') }}</th>
<th>{{ trans('settings.recycle_bin_deleted_by') }}</th>
<th>{{ trans('settings.recycle_bin_deleted_at') }}</th>
</tr>
@if(count($deletions) === 0)
<tr>
<td colspan="3">
<p class="text-muted"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
</td>
</tr>
@endif
@foreach($deletions as $deletion)
<tr>
<td>
<div class="table-entity-item mb-m">
<span role="presentation" class="icon text-{{$deletion->deletable->getType()}}">@icon($deletion->deletable->getType())</span>
<div class="text-{{ $deletion->deletable->getType() }}">
{{ $deletion->deletable->name }}
</div>
</div>
@if($deletion->deletable instanceof \BookStack\Entities\Book)
<div class="pl-xl block inline">
<div class="text-chapter">
@icon('chapter') {{ trans_choice('entities.x_chapters', $deletion->deletable->chapters()->withTrashed()->count()) }}
</div>
</div>
@endif
@if($deletion->deletable instanceof \BookStack\Entities\Book || $deletion->deletable instanceof \BookStack\Entities\Chapter)
<div class="pl-xl block inline">
<div class="text-page">
@icon('page') {{ trans_choice('entities.x_pages', $deletion->deletable->pages()->withTrashed()->count()) }}
</div>
</div>
@endif
</td>
<td>@include('partials.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</td>
<td>{{ $deletion->created_at }}</td>
</tr>
@endforeach
</table>
{!! $deletions->links() !!}
</div>
</div>
@stop

View file

@ -166,6 +166,10 @@ Route::group(['middleware' => 'auth'], function () {
Route::delete('/maintenance/cleanup-images', 'MaintenanceController@cleanupImages');
Route::post('/maintenance/send-test-email', 'MaintenanceController@sendTestEmail');
// Recycle Bin
Route::get('/recycle-bin', 'RecycleBinController@index');
Route::post('/recycle-bin/empty', 'RecycleBinController@empty');
// Audit Log
Route::get('/audit', 'AuditLogController@index');