Merge pull request #2748 from BookStackApp/favourite_system

Favourite System
This commit is contained in:
Dan Brown 2021-05-23 14:45:42 +01:00 committed by GitHub
commit dd6076049c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 592 additions and 167 deletions

17
app/Actions/Favourite.php Normal file
View File

@ -0,0 +1,17 @@
<?php namespace BookStack\Actions;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model
{
protected $fillable = ['user_id'];
/**
* Get the related model that can be favourited.
*/
public function favouritable(): MorphTo
{
return $this->morphTo();
}
}

View File

@ -1,7 +1,19 @@
<?php namespace BookStack\Actions;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Class View
* Views are stored per-item per-person within the database.
* They can be used to find popular items or recently viewed items
* at a per-person level. They do not record every view instance as an
* activity. Only the latest and original view times could be recognised.
*
* @property int $views
* @property int $user_id
*/
class View extends Model
{
@ -9,10 +21,37 @@ class View extends Model
/**
* Get all owning viewable models.
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function viewable()
public function viewable(): MorphTo
{
return $this->morphTo();
}
/**
* Increment the current user's view count for the given viewable model.
*/
public static function incrementFor(Viewable $viewable): int
{
$user = user();
if (is_null($user) || $user->isDefault()) {
return 0;
}
/** @var View $view */
$view = $viewable->views()->firstOrNew([
'user_id' => $user->id,
], ['views' => 0]);
$view->forceFill(['views' => $view->views + 1])->save();
return $view->views;
}
/**
* Clear all views from the system.
*/
public static function clearAll()
{
static::query()->truncate();
}
}

View File

@ -1,117 +0,0 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\EntityProvider;
use DB;
use Illuminate\Support\Collection;
class ViewService
{
protected $view;
protected $permissionService;
protected $entityProvider;
/**
* ViewService constructor.
* @param View $view
* @param PermissionService $permissionService
* @param EntityProvider $entityProvider
*/
public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
{
$this->view = $view;
$this->permissionService = $permissionService;
$this->entityProvider = $entityProvider;
}
/**
* Add a view to the given entity.
* @param \BookStack\Entities\Models\Entity $entity
* @return int
*/
public function add(Entity $entity)
{
$user = user();
if ($user === null || $user->isDefault()) {
return 0;
}
$view = $entity->views()->where('user_id', '=', $user->id)->first();
// Add view if model exists
if ($view) {
$view->increment('views');
return $view->views;
}
// Otherwise create new view count
$entity->views()->save($this->view->newInstance([
'user_id' => $user->id,
'views' => 1
]));
return 1;
}
/**
* Get the entities with the most views.
* @param int $count
* @param int $page
* @param string|array $filterModels
* @param string $action - used for permission checking
* @return Collection
*/
public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view')
{
$skipCount = $count * $page;
$query = $this->permissionService
->filterRestrictedEntityRelations($this->view->newQuery(), 'views', 'viewable_id', 'viewable_type', $action)
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModels) {
$query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
}
return $query->with('viewable')
->skip($skipCount)
->take($count)
->get()
->pluck('viewable')
->filter();
}
/**
* Get all recently viewed entities for the current user.
*/
public function getUserRecentlyViewed(int $count = 10, int $page = 1)
{
$user = user();
if ($user === null || $user->isDefault()) {
return collect();
}
$all = collect();
/** @var Entity $instance */
foreach ($this->entityProvider->all() as $name => $instance) {
$items = $instance::visible()->withLastView()
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc')
->skip($count * ($page - 1))
->take($count)
->get();
$all = $all->concat($items);
}
return $all->sortByDesc('last_viewed_at')->slice(0, $count);
}
/**
* Reset all view counts by deleting all views.
*/
public function resetAll()
{
$this->view->truncate();
}
}

View File

@ -580,14 +580,15 @@ class PermissionService
/**
* Filter items that have entities set as a polymorphic relation.
* @param Builder|\Illuminate\Database\Query\Builder $query
*/
public function filterRestrictedEntityRelations(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view'): Builder
public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$q = $query->where(function ($query) use ($tableDetails, $action) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
$permissionQuery->select('id')->from('joint_permissions')
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('action', '=', $action)

View File

@ -1,5 +1,6 @@
<?php namespace BookStack\Auth;
use BookStack\Actions\Favourite;
use BookStack\Api\ApiToken;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Loggable;
@ -240,6 +241,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $this->hasMany(ApiToken::class);
}
/**
* Get the favourite instances for this user.
*/
public function favourites(): HasMany
{
return $this->hasMany(Favourite::class);
}
/**
* Get the last activity time for this user.
*/

View File

@ -184,6 +184,7 @@ class UserRepo
{
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->delete();
// Delete user profile images

View File

@ -184,11 +184,9 @@ return [
// Custom BookStack
'Activity' => BookStack\Facades\Activity::class,
'Views' => BookStack\Facades\Views::class,
'Images' => BookStack\Facades\Images::class,
'Permissions' => BookStack\Facades\Permissions::class,
'Theme' => BookStack\Facades\Theme::class,
],
// Proxy configuration

View File

@ -2,6 +2,7 @@
namespace BookStack\Console\Commands;
use BookStack\Actions\View;
use Illuminate\Console\Command;
class ClearViews extends Command
@ -36,7 +37,7 @@ class ClearViews extends Command
*/
public function handle()
{
\Views::resetAll();
View::clearAll();
$this->comment('Views cleared');
}
}

View File

@ -2,6 +2,7 @@
use BookStack\Actions\Activity;
use BookStack\Actions\Comment;
use BookStack\Actions\Favourite;
use BookStack\Actions\Tag;
use BookStack\Actions\View;
use BookStack\Auth\Permissions\EntityPermission;
@ -9,7 +10,9 @@ use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Interfaces\Favouritable;
use BookStack\Interfaces\Sluggable;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
@ -38,7 +41,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model implements Sluggable
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
{
use SoftDeletes;
use HasCreatorAndUpdater;
@ -297,4 +300,22 @@ abstract class Entity extends Model implements Sluggable
$this->slug = app(SlugGenerator::class)->generate($this);
return $this->slug;
}
/**
* @inheritdoc
*/
public function favourites(): MorphMany
{
return $this->morphMany(Favourite::class, 'favouritable');
}
/**
* Check if the entity is a favourite of the current user.
*/
public function isFavourite(): bool
{
return $this->favourites()
->where('user_id', '=', user()->id)
->exists();
}
}

View File

@ -0,0 +1,17 @@
<?php namespace BookStack\Entities\Queries;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\EntityProvider;
abstract class EntityQuery
{
protected function permissionService(): PermissionService
{
return app()->make(PermissionService::class);
}
protected function entityProvider(): EntityProvider
{
return app()->make(EntityProvider::class);
}
}

View File

@ -0,0 +1,29 @@
<?php namespace BookStack\Entities\Queries;
use BookStack\Actions\View;
use Illuminate\Support\Facades\DB;
class Popular extends EntityQuery
{
public function run(int $count, int $page, array $filterModels = null, string $action = 'view')
{
$query = $this->permissionService()
->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action)
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModels) {
$query->whereIn('viewable_type', $this->entityProvider()->getMorphClasses($filterModels));
}
return $query->with('viewable')
->skip($count * ($page - 1))
->take($count)
->get()
->pluck('viewable')
->filter();
}
}

View File

@ -0,0 +1,32 @@
<?php namespace BookStack\Entities\Queries;
use BookStack\Actions\View;
use Illuminate\Support\Collection;
class RecentlyViewed extends EntityQuery
{
public function run(int $count, int $page): Collection
{
$user = user();
if ($user === null || $user->isDefault()) {
return collect();
}
$query = $this->permissionService()->filterRestrictedEntityRelations(
View::query(),
'views',
'viewable_id',
'viewable_type',
'view'
)
->orderBy('views.updated_at', 'desc')
->where('user_id', '=', user()->id);
return $query->with('viewable')
->skip(($page - 1) * $count)
->take($count)
->get()
->pluck('viewable')
->filter();
}
}

View File

@ -0,0 +1,33 @@
<?php namespace BookStack\Entities\Queries;
use BookStack\Actions\Favourite;
use Illuminate\Database\Query\JoinClause;
class TopFavourites extends EntityQuery
{
public function run(int $count, int $skip = 0)
{
$user = user();
if (is_null($user) || $user->isDefault()) {
return collect();
}
$query = $this->permissionService()
->filterRestrictedEntityRelations(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type', 'view')
->select('favourites.*')
->leftJoin('views', function (JoinClause $join) {
$join->on('favourites.favouritable_id', '=', 'views.viewable_id');
$join->on('favourites.favouritable_type', '=', 'views.viewable_type');
$join->where('views.user_id', '=', user()->id);
})
->orderBy('views.views', 'desc')
->where('favourites.user_id', '=', user()->id);
return $query->with('favouritable')
->skip($skip)
->take($count)
->get()
->pluck('favouritable')
->filter();
}
}

View File

@ -317,6 +317,7 @@ class TrashCan
$entity->jointPermissions()->delete();
$entity->searchTerms()->delete();
$entity->deletions()->delete();
$entity->favourites()->delete();
if ($entity instanceof HasCoverImage && $entity->cover) {
$imageService = app()->make(ImageService::class);

View File

@ -1,16 +0,0 @@
<?php namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
class Views extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'views';
}
}

View File

@ -2,6 +2,7 @@
use Activity;
use BookStack\Actions\ActivityType;
use BookStack\Actions\View;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Tools\PermissionsUpdater;
@ -112,7 +113,7 @@ class BookController extends Controller
$bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->visible()->get();
Views::add($book);
View::incrementFor($book);
if ($request->has('shelf')) {
$this->entityContextManager->setShelfContext(intval($request->get('shelf')));
}

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Entities\Tools\ShelfContext;
@ -109,7 +110,7 @@ class BookshelfController extends Controller
->values()
->all();
Views::add($shelf);
View::incrementFor($shelf);
$this->entityContextManager->setShelfContext($shelf->id);
$view = setting()->getForCurrentUser('bookshelf_view_type');

View File

@ -1,5 +1,6 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Actions\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Repos\ChapterRepo;
@ -64,7 +65,7 @@ class ChapterController extends Controller
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $chapter->getVisiblePages();
Views::add($chapter);
View::incrementFor($chapter);
$this->setPageTitle($chapter->getShortName());
return view('chapters.show', [

View File

@ -0,0 +1,95 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Interfaces\Favouritable;
use BookStack\Model;
use Illuminate\Http\Request;
class FavouriteController extends Controller
{
/**
* Show a listing of all favourite items for the current user.
*/
public function index(Request $request)
{
$viewCount = 20;
$page = intval($request->get('page', 1));
$favourites = (new TopFavourites)->run($viewCount + 1, (($page - 1) * $viewCount));
$hasMoreLink = ($favourites->count() > $viewCount) ? url("/favourites?page=" . ($page+1)) : null;
return view('common.detailed-listing-with-more', [
'title' => trans('entities.my_favourites'),
'entities' => $favourites->slice(0, $viewCount),
'hasMoreLink' => $hasMoreLink,
]);
}
/**
* Add a new item as a favourite.
*/
public function add(Request $request)
{
$favouritable = $this->getValidatedModelFromRequest($request);
$favouritable->favourites()->firstOrCreate([
'user_id' => user()->id,
]);
$this->showSuccessNotification(trans('activities.favourite_add_notification', [
'name' => $favouritable->name,
]));
return redirect()->back();
}
/**
* Remove an item as a favourite.
*/
public function remove(Request $request)
{
$favouritable = $this->getValidatedModelFromRequest($request);
$favouritable->favourites()->where([
'user_id' => user()->id,
])->delete();
$this->showSuccessNotification(trans('activities.favourite_remove_notification', [
'name' => $favouritable->name,
]));
return redirect()->back();
}
/**
* @throws \Illuminate\Validation\ValidationException
* @throws \Exception
*/
protected function getValidatedModelFromRequest(Request $request): Favouritable
{
$modelInfo = $this->validate($request, [
'type' => 'required|string',
'id' => 'required|integer',
]);
if (!class_exists($modelInfo['type'])) {
throw new \Exception('Model not found');
}
/** @var Model $model */
$model = new $modelInfo['type'];
if (! $model instanceof Favouritable) {
throw new \Exception('Model not favouritable');
}
$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name']);
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {
throw new \Exception('Model instance not found');
}
return $modelInstance;
}
}

View File

@ -2,11 +2,12 @@
use Activity;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Queries\RecentlyViewed;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use Illuminate\Http\Response;
use Views;
class HomeController extends Controller
@ -32,12 +33,13 @@ class HomeController extends Controller
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
$recents = $this->isSignedIn() ?
Views::getUserRecentlyViewed(12*$recentFactor, 1)
(new RecentlyViewed)->run(12*$recentFactor, 1)
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
$favourites = (new TopFavourites)->run(6);
$recentlyUpdatedPages = Page::visible()->with('book')
->where('draft', false)
->orderBy('updated_at', 'desc')
->take(12)
->take($favourites->count() > 0 ? 6 : 12)
->get();
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
@ -51,6 +53,7 @@ class HomeController extends Controller
'recents' => $recents,
'recentlyUpdatedPages' => $recentlyUpdatedPages,
'draftPages' => $draftPages,
'favourites' => $favourites,
];
// Add required list ordering & sorting for books & shelves views.

View File

@ -1,5 +1,6 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Actions\View;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity;
@ -141,7 +142,7 @@ class PageController extends Controller
$page->load(['comments.createdBy']);
}
Views::add($page);
View::incrementFor($page);
$this->setPageTitle($page->getShortName());
return view('pages.show', [
'page' => $page,
@ -337,9 +338,9 @@ class PageController extends Controller
->paginate(20)
->setPath(url('/pages/recently-updated'));
return view('pages.detailed-listing', [
return view('common.detailed-listing-paginated', [
'title' => trans('entities.recently_updated_pages'),
'pages' => $pages
'entities' => $pages
]);
}

View File

@ -1,6 +1,6 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Actions\ViewService;
use BookStack\Entities\Queries\Popular;
use BookStack\Entities\Tools\SearchRunner;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Entities\Tools\SearchOptions;
@ -9,16 +9,13 @@ use Illuminate\Http\Request;
class SearchController extends Controller
{
protected $viewService;
protected $searchRunner;
protected $entityContextManager;
public function __construct(
ViewService $viewService,
SearchRunner $searchRunner,
ShelfContext $entityContextManager
) {
$this->viewService = $viewService;
$this->searchRunner = $searchRunner;
$this->entityContextManager = $entityContextManager;
}
@ -82,7 +79,7 @@ class SearchController extends Controller
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
} else {
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
$entities = (new Popular)->run(20, 0, $entityTypes, $permission);
}
return view('search.entity-ajax-list', ['entities' => $entities]);

View File

@ -0,0 +1,11 @@
<?php namespace BookStack\Interfaces;
use Illuminate\Database\Eloquent\Relations\MorphMany;
interface Favouritable
{
/**
* Get the related favourite instances.
*/
public function favourites(): MorphMany;
}

View File

@ -0,0 +1,11 @@
<?php namespace BookStack\Interfaces;
use Illuminate\Database\Eloquent\Relations\MorphMany;
interface Viewable
{
/**
* Get all view instances for this viewable model.
*/
public function views(): MorphMany;
}

View File

@ -3,7 +3,6 @@
namespace BookStack\Providers;
use BookStack\Actions\ActivityService;
use BookStack\Actions\ViewService;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Theming\ThemeService;
use BookStack\Uploads\ImageService;
@ -32,10 +31,6 @@ class CustomFacadeProvider extends ServiceProvider
return $this->app->make(ActivityService::class);
});
$this->app->singleton('views', function () {
return $this->app->make(ViewService::class);
});
$this->app->singleton('images', function () {
return $this->app->make(ImageService::class);
});

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateFavouritesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('favourites', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->index();
$table->integer('favouritable_id');
$table->string('favouritable_type', 100);
$table->timestamps();
$table->index(['favouritable_id', 'favouritable_type'], 'favouritable_index');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('favourites');
}
}

View File

@ -1,4 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
</svg>

Before

Width:  |  Height:  |  Size: 307 B

After

Width:  |  Height:  |  Size: 265 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/></svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -43,6 +43,10 @@ return [
'bookshelf_delete' => 'deleted bookshelf',
'bookshelf_delete_notification' => 'Bookshelf Successfully Deleted',
// Favourites
'favourite_add_notification' => '":name" has been added to your favourites',
'favourite_remove_notification' => '":name" has been removed from your favourites',
// Other
'commented_on' => 'commented on',
'permissions_update' => 'updated permissions',

View File

@ -40,6 +40,8 @@ return [
'remove' => 'Remove',
'add' => 'Add',
'fullscreen' => 'Fullscreen',
'favourite' => 'Favourite',
'unfavourite' => 'Unfavourite',
// Sort Options
'sort_options' => 'Sort Options',

View File

@ -27,6 +27,8 @@ return [
'images' => 'Images',
'my_recent_drafts' => 'My Recent Drafts',
'my_recently_viewed' => 'My Recently Viewed',
'my_most_viewed_favourites' => 'My Most Viewed Favourites',
'my_favourites' => 'My Favourites',
'no_pages_viewed' => 'You have not viewed any pages',
'no_pages_recently_created' => 'No pages have been recently created',
'no_pages_recently_updated' => 'No pages have been recently updated',

View File

@ -118,6 +118,9 @@
<hr class="primary-background">
@if(signedInUser())
@include('partials.entity-favourite-action', ['entity' => $book])
@endif
@include('partials.entity-export-menu', ['entity' => $book])
</div>
</div>

View File

@ -123,6 +123,9 @@
<hr class="primary-background"/>
@if(signedInUser())
@include('partials.entity-favourite-action', ['entity' => $chapter])
@endif
@include('partials.entity-export-menu', ['entity' => $chapter])
</div>
</div>

View File

@ -6,11 +6,11 @@
<h1 class="list-heading">{{ $title }}</h1>
<div class="book-contents">
@include('partials.entity-list', ['entities' => $pages, 'style' => 'detailed'])
@include('partials.entity-list', ['entities' => $entities, 'style' => 'detailed'])
</div>
<div class="text-center">
{!! $pages->links() !!}
{!! $entities->links() !!}
</div>
</main>
</div>

View File

@ -0,0 +1,19 @@
@extends('simple-layout')
@section('body')
<div class="container small pt-xl">
<main class="card content-wrap">
<h1 class="list-heading">{{ $title }}</h1>
<div class="book-contents">
@include('partials.entity-list', ['entities' => $entities, 'style' => 'detailed'])
</div>
<div class="text-right">
@if($hasMoreLink)
<a href="{{ $hasMoreLink }}" class="button outline">{{ trans('common.more') }}</a>
@endif
</div>
</main>
</div>
@stop

View File

@ -61,6 +61,9 @@
<span class="name">{{ $currentUser->getShortName(9) }}</span> @icon('caret-down')
</span>
<ul refs="dropdown@menu" class="dropdown-menu" role="menu">
<li>
<a href="{{ url('/favourites') }}">@icon('star'){{ trans('entities.my_favourites') }}</a>
</li>
<li>
<a href="{{ $currentUser->getProfileUrl() }}">@icon('user'){{ trans('common.view_profile') }}</a>
</li>

View File

@ -5,6 +5,20 @@
</div>
@endif
@if(count($favourites) > 0)
<div id="top-favourites" class="card mb-xl">
<h3 class="card-title">
<a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
</h3>
<div class="px-m">
@include('partials.entity-list', [
'entities' => $favourites,
'style' => 'compact',
])
</div>
</div>
@endif
<div class="mb-xl">
<h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
@include('partials.entity-list', [

View File

@ -42,6 +42,20 @@
</div>
<div>
@if(count($favourites) > 0)
<div id="top-favourites" class="card mb-xl">
<h3 class="card-title">
<a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
</h3>
<div class="px-m">
@include('partials.entity-list', [
'entities' => $favourites,
'style' => 'compact',
])
</div>
</div>
@endif
<div id="recent-pages" class="card mb-xl">
<h3 class="card-title"><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h3>
<div id="recently-updated-pages" class="px-m">

View File

@ -26,7 +26,7 @@
<div class="card mb-xl">
<h3 class="card-title">{{ trans('entities.pages_popular') }}</h3>
<div class="px-m">
@include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['page']), 'style' => 'compact'])
@include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['page']), 'style' => 'compact'])
</div>
</div>
</div>
@ -34,7 +34,7 @@
<div class="card mb-xl">
<h3 class="card-title">{{ trans('entities.books_popular') }}</h3>
<div class="px-m">
@include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['book']), 'style' => 'compact'])
@include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['book']), 'style' => 'compact'])
</div>
</div>
</div>
@ -42,7 +42,7 @@
<div class="card mb-xl">
<h3 class="card-title">{{ trans('entities.chapters_popular') }}</h3>
<div class="px-m">
@include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['chapter']), 'style' => 'compact'])
@include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['chapter']), 'style' => 'compact'])
</div>
</div>
</div>

View File

@ -150,7 +150,9 @@
<hr class="primary-background"/>
{{--Export--}}
@if(signedInUser())
@include('partials.entity-favourite-action', ['entity' => $page])
@endif
@include('partials.entity-export-menu', ['entity' => $page])
</div>

View File

@ -0,0 +1,12 @@
@php
$isFavourite = $entity->isFavourite();
@endphp
<form action="{{ url('/favourites/' . ($isFavourite ? 'remove' : 'add')) }}" method="POST">
{{ csrf_field() }}
<input type="hidden" name="type" value="{{ get_class($entity) }}">
<input type="hidden" name="id" value="{{ $entity->id }}">
<button type="submit" class="icon-list-item text-primary">
<span>@icon($isFavourite ? 'star' : 'star-outline')</span>
<span>{{ $isFavourite ? trans('common.unfavourite') : trans('common.favourite') }}</span>
</button>
</form>

View File

@ -133,6 +133,11 @@
</a>
@endif
@if(signedInUser())
<hr class="primary-background">
@include('partials.entity-favourite-action', ['entity' => $shelf])
@endif
</div>
</div>
@stop

View File

@ -152,9 +152,15 @@ Route::group(['middleware' => 'auth'], function () {
// User Search
Route::get('/search/users/select', 'UserSearchController@forSelect');
// Template System
Route::get('/templates', 'PageTemplateController@list');
Route::get('/templates/{templateId}', 'PageTemplateController@get');
// Favourites
Route::get('/favourites', 'FavouriteController@index');
Route::post('/favourites/add', 'FavouriteController@add');
Route::post('/favourites/remove', 'FavouriteController@remove');
// Other Pages
Route::get('/', 'HomeController@index');
Route::get('/home', 'HomeController@index');

118
tests/FavouriteTest.php Normal file
View File

@ -0,0 +1,118 @@
<?php
use BookStack\Actions\Favourite;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Tests\TestCase;
class FavouriteTest extends TestCase
{
public function test_page_add_favourite_flow()
{
$page = Page::query()->first();
$editor = $this->getEditor();
$resp = $this->actingAs($editor)->get($page->getUrl());
$resp->assertElementContains('button', 'Favourite');
$resp->assertElementExists('form[method="POST"][action$="/favourites/add"]');
$resp = $this->post('/favourites/add', [
'type' => get_class($page),
'id' => $page->id,
]);
$resp->assertRedirect($page->getUrl());
$resp->assertSessionHas('success', "\"{$page->name}\" has been added to your favourites");
$this->assertDatabaseHas('favourites', [
'user_id' => $editor->id,
'favouritable_type' => $page->getMorphClass(),
'favouritable_id' => $page->id,
]);
}
public function test_page_remove_favourite_flow()
{
$page = Page::query()->first();
$editor = $this->getEditor();
Favourite::query()->forceCreate([
'user_id' => $editor->id,
'favouritable_id' => $page->id,
'favouritable_type' => $page->getMorphClass(),
]);
$resp = $this->actingAs($editor)->get($page->getUrl());
$resp->assertElementContains('button', 'Unfavourite');
$resp->assertElementExists('form[method="POST"][action$="/favourites/remove"]');
$resp = $this->post('/favourites/remove', [
'type' => get_class($page),
'id' => $page->id,
]);
$resp->assertRedirect($page->getUrl());
$resp->assertSessionHas('success', "\"{$page->name}\" has been removed from your favourites");
$this->assertDatabaseMissing('favourites', [
'user_id' => $editor->id,
]);
}
public function test_book_chapter_shelf_pages_contain_favourite_button()
{
$entities = [
Bookshelf::query()->first(),
Book::query()->first(),
Chapter::query()->first(),
];
$this->actingAs($this->getEditor());
foreach ($entities as $entity) {
$resp = $this->get($entity->getUrl());
$resp->assertElementExists('form[method="POST"][action$="/favourites/add"]');
}
}
public function test_header_contains_link_to_favourites_page_when_logged_in()
{
$this->setSettings(['app-public' => 'true']);
$this->get('/')->assertElementNotContains('header', 'My Favourites');
$this->actingAs($this->getViewer())->get('/')->assertElementContains('header a', 'My Favourites');
}
public function test_favourites_shown_on_homepage()
{
$editor = $this->getEditor();
$resp = $this->actingAs($editor)->get('/');
$resp->assertElementNotExists('#top-favourites');
/** @var Page $page */
$page = Page::query()->first();
$page->favourites()->save((new Favourite)->forceFill(['user_id' => $editor->id]));
$resp = $this->get('/');
$resp->assertElementExists('#top-favourites');
$resp->assertElementContains('#top-favourites', $page->name);
}
public function test_favourites_list_page_shows_favourites_and_has_working_pagination()
{
/** @var Page $page */
$page = Page::query()->first();
$editor = $this->getEditor();
$resp = $this->actingAs($editor)->get('/favourites');
$resp->assertDontSee($page->name);
$page->favourites()->save((new Favourite)->forceFill(['user_id' => $editor->id]));
$resp = $this->get('/favourites');
$resp->assertSee($page->name);
$resp = $this->get('/favourites?page=2');
$resp->assertDontSee($page->name);
}
}