Dan Brown

Merge branch 'master' into release

Showing 57 changed files with 820 additions and 96 deletions
dist: trusty
sudo: required
language: php
php:
- 7.0
......@@ -5,15 +7,21 @@ php:
cache:
directories:
- vendor
- node_modules
- $HOME/.composer/cache
addons:
mariadb: '10.0'
apt:
packages:
- mysql-server-5.6
- mysql-client-core-5.6
- mysql-client-5.6
before_install:
- npm install -g npm@latest
before_script:
- mysql -e 'create database `bookstack-test`;'
- mysql -u root -e 'create database `bookstack-test`;'
- composer config -g github-oauth.github.com $GITHUB_ACCESS_TOKEN
- phpenv config-rm xdebug.ini
- composer self-update
......
The MIT License (MIT)
Copyright (c) 2016 Dan Brown
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
......@@ -44,7 +44,7 @@ class Activity extends Model
* @return bool
*/
public function isSimilarTo($activityB) {
return [$this->key, $this->entitiy_type, $this->entitiy_id] === [$activityB->key, $activityB->entitiy_type, $activityB->entitiy_id];
return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
}
}
......
......@@ -59,7 +59,7 @@ class ChapterController extends Controller
$input = $request->all();
$input['priority'] = $this->bookRepo->getNewPriority($book);
$chapter = $this->chapterRepo->createFromInput($request->all(), $book);
$chapter = $this->chapterRepo->createFromInput($input, $book);
Activity::add($chapter, 'chapter_create', $book->id);
return redirect($chapter->getUrl());
}
......@@ -155,6 +155,63 @@ class ChapterController extends Controller
}
/**
* Show the page for moving a chapter.
* @param $bookSlug
* @param $chapterSlug
* @return mixed
* @throws \BookStack\Exceptions\NotFoundException
*/
public function showMove($bookSlug, $chapterSlug) {
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('chapter-update', $chapter);
return view('chapters/move', [
'chapter' => $chapter,
'book' => $book
]);
}
/**
* Perform the move action for a chapter.
* @param $bookSlug
* @param $chapterSlug
* @param Request $request
* @return mixed
* @throws \BookStack\Exceptions\NotFoundException
*/
public function move($bookSlug, $chapterSlug, Request $request) {
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('chapter-update', $chapter);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($chapter->getUrl());
}
$stringExploded = explode(':', $entitySelection);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
$parent = false;
if ($entityType == 'book') {
$parent = $this->bookRepo->getById($entityId);
}
if ($parent === false || $parent === null) {
session()->flash('The selected Book was not found');
return redirect()->back();
}
$this->chapterRepo->changeBook($parent->id, $chapter);
Activity::add($chapter, 'chapter_move', $chapter->book->id);
session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name));
return redirect($chapter->getUrl());
}
/**
* Show the Restrictions view.
* @param $bookSlug
* @param $chapterSlug
......
......@@ -53,7 +53,7 @@ class ImageController extends Controller
]);
$searchTerm = $request->get('term');
$imgData = $this->imageRepo->searchPaginatedByType($type, $page,24, $searchTerm);
$imgData = $this->imageRepo->searchPaginatedByType($type, $page, 24, $searchTerm);
return response()->json($imgData);
}
......@@ -99,7 +99,7 @@ class ImageController extends Controller
{
$this->checkPermission('image-create-all');
$this->validate($request, [
'file' => 'image|mimes:jpeg,gif,png'
'file' => 'is_image'
]);
$imageUpload = $request->file('file');
......
......@@ -92,7 +92,7 @@ class PageController extends Controller
$draftPage = $this->pageRepo->getById($pageId, true);
$chapterId = $draftPage->chapter_id;
$chapterId = intval($draftPage->chapter_id);
$parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
$this->checkOwnablePermission('page-create', $parent);
......@@ -451,6 +451,67 @@ class PageController extends Controller
}
/**
* Show the view to choose a new parent to move a page into.
* @param $bookSlug
* @param $pageSlug
* @return mixed
* @throws NotFoundException
*/
public function showMove($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$this->checkOwnablePermission('page-update', $page);
return view('pages/move', [
'book' => $book,
'page' => $page
]);
}
/**
* Does the action of moving the location of a page
* @param $bookSlug
* @param $pageSlug
* @param Request $request
* @return mixed
* @throws NotFoundException
*/
public function move($bookSlug, $pageSlug, Request $request)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$this->checkOwnablePermission('page-update', $page);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($page->getUrl());
}
$stringExploded = explode(':', $entitySelection);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
$parent = false;
if ($entityType == 'chapter') {
$parent = $this->chapterRepo->getById($entityId);
} else if ($entityType == 'book') {
$parent = $this->bookRepo->getById($entityId);
}
if ($parent === false || $parent === null) {
session()->flash('The selected Book or Chapter was not found');
return redirect()->back();
}
$this->pageRepo->changePageParent($page, $parent);
Activity::add($page, 'page_move', $page->book->id);
session()->flash('success', sprintf('Page moved to "%s"', $parent->name));
return redirect($page->getUrl());
}
/**
* Set the permissions for this page.
* @param $bookSlug
* @param $pageSlug
......
......@@ -2,10 +2,10 @@
namespace BookStack\Http\Controllers;
use BookStack\Services\ViewService;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo;
use BookStack\Repos\PageRepo;
......@@ -15,18 +15,21 @@ class SearchController extends Controller
protected $pageRepo;
protected $bookRepo;
protected $chapterRepo;
protected $viewService;
/**
* SearchController constructor.
* @param $pageRepo
* @param $bookRepo
* @param $chapterRepo
* @param PageRepo $pageRepo
* @param BookRepo $bookRepo
* @param ChapterRepo $chapterRepo
* @param ViewService $viewService
*/
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo)
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ViewService $viewService)
{
$this->pageRepo = $pageRepo;
$this->bookRepo = $bookRepo;
$this->chapterRepo = $chapterRepo;
$this->viewService = $viewService;
parent::__construct();
}
......@@ -134,4 +137,35 @@ class SearchController extends Controller
return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
}
/**
* Search for a list of entities and return a partial HTML response of matching entities.
* Returns the most popular entities if no search is provided.
* @param Request $request
* @return mixed
*/
public function searchEntitiesAjax(Request $request)
{
$entities = collect();
$entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']);
$searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false;
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
if ($entityTypes->contains('page')) $entities = $entities->merge($this->pageRepo->getBySearch($searchTerm)->items());
if ($entityTypes->contains('chapter')) $entities = $entities->merge($this->chapterRepo->getBySearch($searchTerm)->items());
if ($entityTypes->contains('book')) $entities = $entities->merge($this->bookRepo->getBySearch($searchTerm)->items());
$entities = $entities->sortByDesc('title_relevance');
} else {
$entityNames = $entityTypes->map(function ($type) {
return 'BookStack\\' . ucfirst($type);
})->toArray();
$entities = $this->viewService->getPopular(20, 0, $entityNames);
}
return view('search/entity-ajax-list', ['entities' => $entities]);
}
}
......
......@@ -55,7 +55,7 @@ class TagController extends Controller
*/
public function getNameSuggestions(Request $request)
{
$searchTerm = $request->get('search');
$searchTerm = $request->has('search') ? $request->get('search') : false;
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
return response()->json($suggestions);
}
......@@ -66,8 +66,9 @@ class TagController extends Controller
*/
public function getValueSuggestions(Request $request)
{
$searchTerm = $request->get('search');
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm);
$searchTerm = $request->has('search') ? $request->get('search') : false;
$tagName = $request->has('name') ? $request->get('name') : false;
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
return response()->json($suggestions);
}
......
......@@ -34,6 +34,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml');
Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText');
Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit');
Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove');
Route::put('/{bookSlug}/page/{pageSlug}/move', 'PageController@move');
Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete');
Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft');
Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict');
......@@ -53,6 +55,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::post('/{bookSlug}/chapter/create', 'ChapterController@store');
Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show');
Route::put('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@update');
Route::get('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@showMove');
Route::put('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@move');
Route::get('/{bookSlug}/chapter/{chapterSlug}/edit', 'ChapterController@edit');
Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showRestrict');
Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@restrict');
......@@ -93,6 +97,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
});
Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
// Links
Route::get('/link/{id}', 'PageController@redirectFromLink');
......
......@@ -15,7 +15,12 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot()
{
//
// Custom validation methods
\Validator::extend('is_image', function($attribute, $value, $parameters, $validator) {
$imageMimes = ['image/png', 'image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/webp'];
return in_array($value->getMimeType(), $imageMimes);
});
}
/**
......
......@@ -251,7 +251,10 @@ class BookRepo extends EntityRepo
}]);
$chapterQuery = $this->permissionService->enforceChapterRestrictions($chapterQuery, 'view');
$chapters = $chapterQuery->get();
$children = $pages->merge($chapters);
$children = $pages->values();
foreach ($chapters as $chapter) {
$children->push($chapter);
}
$bookSlug = $book->slug;
$children->each(function ($child) use ($bookSlug) {
......
......@@ -9,6 +9,18 @@ use BookStack\Chapter;
class ChapterRepo extends EntityRepo
{
protected $pageRepo;
/**
* ChapterRepo constructor.
* @param $pageRepo
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Base query for getting chapters, Takes permissions into account.
* @return mixed
......@@ -189,12 +201,21 @@ class ChapterRepo extends EntityRepo
public function changeBook($bookId, Chapter $chapter)
{
$chapter->book_id = $bookId;
// Update related activity
foreach ($chapter->activity as $activity) {
$activity->book_id = $bookId;
$activity->save();
}
$chapter->slug = $this->findSuitableSlug($chapter->name, $bookId, $chapter->id);
$chapter->save();
// Update all child pages
foreach ($chapter->pages as $page) {
$this->pageRepo->changeBook($bookId, $page);
}
// Update permissions
$chapter->load('book');
$this->permissionService->buildJointPermissionsForEntity($chapter->book);
return $chapter;
}
......
......@@ -3,6 +3,7 @@
use Activity;
use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\Exceptions\NotFoundException;
use Carbon\Carbon;
use DOMDocument;
......@@ -572,6 +573,22 @@ class PageRepo extends EntityRepo
return $page;
}
/**
* Change the page's parent to the given entity.
* @param Page $page
* @param Entity $parent
*/
public function changePageParent(Page $page, Entity $parent)
{
$book = $parent->isA('book') ? $parent : $parent->book;
$page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
$page->save();
$page = $this->changeBook($book->id, $page);
$page->load('book');
$this->permissionService->buildJointPermissionsForEntity($book);
}
/**
* Gets a suitable slug for the resource
* @param $name
......
......@@ -58,29 +58,48 @@ class TagRepo
/**
* Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided.
* @param $searchTerm
* @return array
*/
public function getNameSuggestions($searchTerm)
public function getNameSuggestions($searchTerm = false)
{
if ($searchTerm === '') return [];
$query = $this->tag->where('name', 'LIKE', $searchTerm . '%')->groupBy('name')->orderBy('name', 'desc');
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
} else {
$query = $query->orderBy('count', 'desc')->take(50);
}
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['name'])->pluck('name');
}
/**
* Get tag value suggestions from scanning existing tag values.
* If no search is given the 50 most popular values are provided.
* Passing a tagName will only find values for a tags with a particular name.
* @param $searchTerm
* @param $tagName
* @return array
*/
public function getValueSuggestions($searchTerm)
public function getValueSuggestions($searchTerm = false, $tagName = false)
{
if ($searchTerm === '') return [];
$query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc');
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
if ($searchTerm) {
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
} else {
$query = $query->orderBy('count', 'desc')->take(50);
}
if ($tagName !== false) $query = $query->where('name', '=', $tagName);
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['value'])->pluck('value');
}
/**
* Save an array of tags to an entity
* @param Entity $entity
......
......@@ -90,7 +90,7 @@ class ActivityService
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get();
->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get();
return $this->filterSimilar($activityList);
}
......
......@@ -4,6 +4,7 @@ use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\JointPermission;
use BookStack\Ownable;
use BookStack\Page;
use BookStack\Role;
use BookStack\User;
......@@ -307,16 +308,16 @@ class PermissionService
/**
* Checks if an entity has a restriction set upon it.
* @param Entity $entity
* @param Ownable $ownable
* @param $permission
* @return bool
*/
public function checkEntityUserAccess(Entity $entity, $permission)
public function checkOwnableUserAccess(Ownable $ownable, $permission)
{
if ($this->isAdmin) return true;
$explodedPermission = explode('-', $permission);
$baseQuery = $entity->where('id', '=', $entity->id);
$baseQuery = $ownable->where('id', '=', $ownable->id);
$action = end($explodedPermission);
$this->currentAction = $action;
......@@ -327,7 +328,7 @@ class PermissionService
$allPermission = $this->currentUser && $this->currentUser->can($permission . '-all');
$ownPermission = $this->currentUser && $this->currentUser->can($permission . '-own');
$this->currentAction = 'view';
$isOwner = $this->currentUser && $this->currentUser->id === $entity->created_by;
$isOwner = $this->currentUser && $this->currentUser->id === $ownable->created_by;
return ($allPermission || ($isOwner && $ownPermission));
}
......
......@@ -50,7 +50,7 @@ class ViewService
* Get the entities with the most views.
* @param int $count
* @param int $page
* @param bool|false $filterModel
* @param bool|false|array $filterModel
*/
public function getPopular($count = 10, $page = 0, $filterModel = false)
{
......@@ -60,7 +60,11 @@ class ViewService
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModel) $query->where('viewable_type', '=', get_class($filterModel));
if ($filterModel && is_array($filterModel)) {
$query->whereIn('viewable_type', $filterModel);
} else if ($filterModel) {
$query->where('viewable_type', '=', get_class($filterModel));
};
return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable');
}
......
<?php
use BookStack\Ownable;
if (!function_exists('versioned_asset')) {
/**
* Get the path to a versioned file.
......@@ -34,18 +36,18 @@ if (!function_exists('versioned_asset')) {
* If an ownable element is passed in the jointPermissions are checked against
* that particular item.
* @param $permission
* @param \BookStack\Ownable $ownable
* @param Ownable $ownable
* @return mixed
*/
function userCan($permission, \BookStack\Ownable $ownable = null)
function userCan($permission, Ownable $ownable = null)
{
if ($ownable === null) {
return auth()->user() && auth()->user()->can($permission);
}
// Check permission on ownable item
$permissionService = app('BookStack\Services\PermissionService');
return $permissionService->checkEntityUserAccess($ownable, $permission);
$permissionService = app(\BookStack\Services\PermissionService::class);
return $permissionService->checkOwnableUserAccess($ownable, $permission);
}
/**
......
......@@ -5,6 +5,8 @@
*/
return [
'app-editor' => 'wysiwyg'
'app-editor' => 'wysiwyg',
'app-color' => '#0288D1',
'app-color-light' => 'rgba(21, 101, 192, 0.15)'
];
\ No newline at end of file
......
......@@ -12,7 +12,13 @@ class CreateBooksTable extends Migration
*/
public function up()
{
Schema::create('books', function (Blueprint $table) {
$pdo = \DB::connection()->getPdo();
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
Schema::create('books', function (Blueprint $table) use ($requiresISAM) {
if($requiresISAM) $table->engine = 'MyISAM';
$table->increments('id');
$table->string('name');
$table->string('slug')->indexed();
......
......@@ -12,7 +12,13 @@ class CreatePagesTable extends Migration
*/
public function up()
{
Schema::create('pages', function (Blueprint $table) {
$pdo = \DB::connection()->getPdo();
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
Schema::create('pages', function (Blueprint $table) use ($requiresISAM) {
if($requiresISAM) $table->engine = 'MyISAM';
$table->increments('id');
$table->integer('book_id');
$table->integer('chapter_id');
......
......@@ -12,7 +12,12 @@ class CreateChaptersTable extends Migration
*/
public function up()
{
Schema::create('chapters', function (Blueprint $table) {
$pdo = \DB::connection()->getPdo();
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
Schema::create('chapters', function (Blueprint $table) use ($requiresISAM) {
if($requiresISAM) $table->engine = 'MyISAM';
$table->increments('id');
$table->integer('book_id');
$table->string('slug')->indexed();
......
# BookStack
[![GitHub release](https://img.shields.io/github/release/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/releases/latest)
[![license](https://img.shields.io/github/license/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
[![Build Status](https://travis-ci.org/ssddanbrown/BookStack.svg)](https://travis-ci.org/ssddanbrown/BookStack)
A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
......
......@@ -379,6 +379,15 @@ module.exports = function (ngApp, events) {
saveDraft();
};
// Listen to shortcuts coming via events
$scope.$on('editor-keydown', (event, data) => {
// Save shortcut (ctrl+s)
if (data.keyCode == 83 && (navigator.platform.match("Mac") ? data.metaKey : data.ctrlKey)) {
data.preventDefault();
saveDraft();
}
});
/**
* Discard the current draft and grab the current page
* content from the system via an AJAX request.
......
......@@ -112,16 +112,11 @@ $(function () {
// Common jQuery actions
$('[data-action="expand-entity-list-details"]').click(function() {
$('.entity-list.compact').find('p').slideToggle(240);
$('.entity-list.compact').find('p').not('.empty-text').slideToggle(240);
});
});
function elemExists(selector) {
return document.querySelector(selector) !== null;
}
// Page specific items
require('./pages/page-show');
......
var mceOptions = module.exports = {
selector: '#html-editor',
content_css: [
'/css/styles.css'
'/css/styles.css',
'/libs/material-design-iconic-font/css/material-design-iconic-font.min.css'
],
body_class: 'page-content',
relative_urls: false,
......@@ -19,11 +20,18 @@ var mceOptions = module.exports = {
{title: "Header 1", format: "h1"},
{title: "Header 2", format: "h2"},
{title: "Header 3", format: "h3"},
{title: "Paragraph", format: "p"},
{title: "Paragraph", format: "p", exact: true, classes: ''},
{title: "Blockquote", format: "blockquote"},
{title: "Code Block", icon: "code", format: "pre"},
{title: "Inline Code", icon: "code", inline: "code"}
{title: "Inline Code", icon: "code", inline: "code"},
{title: "Callouts", items: [
{title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
{title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
{title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
{title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
]}
],
style_formats_merge: false,
formats: {
alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
......
......@@ -74,15 +74,15 @@ window.setupPageShow = module.exports = function (pageId) {
// Make the book-tree sidebar stick in view on scroll
var $window = $(window);
var $bookTree = $(".book-tree");
var $bookTreeParent = $bookTree.parent();
// Check the page is scrollable and the content is taller than the tree
var pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height());
// Get current tree's width and header height
var headerHeight = $("#header").height() + $(".toolbar").height();
var isFixed = $window.scrollTop() > headerHeight;
var bookTreeWidth = $bookTree.width();
// Function to fix the tree as a sidebar
function stickTree() {
$bookTree.width(bookTreeWidth + 48 + 15);
$bookTree.width($bookTreeParent.width() + 15);
$bookTree.addClass("fixed");
isFixed = true;
}
......@@ -101,13 +101,27 @@ window.setupPageShow = module.exports = function (pageId) {
unstickTree();
}
}
// The event ran when the window scrolls
function windowScrollEvent() {
checkTreeStickiness(false);
}
// If the page is scrollable and the window is wide enough listen to scroll events
// and evaluate tree stickiness.
if (pageScrollable && $window.width() > 1000) {
$window.scroll(function() {
checkTreeStickiness(false);
});
$window.on('scroll', windowScrollEvent);
checkTreeStickiness(true);
}
// Handle window resizing and switch between desktop/mobile views
$window.on('resize', event => {
if (pageScrollable && $window.width() > 1000) {
$window.on('scroll', windowScrollEvent);
checkTreeStickiness(true);
} else {
$window.off('scroll', windowScrollEvent);
unstickTree();
}
});
};
......
......@@ -125,3 +125,51 @@
margin-right: $-xl;
}
}
/**
* Callouts
*/
.callout {
border-left: 3px solid #BBB;
background-color: #EEE;
padding: $-s;
&:before {
font-family: 'Material-Design-Iconic-Font';
padding-right: $-s;
display: inline-block;
}
&.success {
border-left-color: $positive;
background-color: lighten($positive, 45%);
color: darken($positive, 16%);
}
&.success:before {
content: '\f269';
}
&.danger {
border-left-color: $negative;
background-color: lighten($negative, 34%);
color: darken($negative, 20%);
}
&.danger:before {
content: '\f1f2';
}
&.info {
border-left-color: $info;
background-color: lighten($info, 50%);
color: darken($info, 16%);
}
&.info:before {
content: '\f1f8';
}
&.warning {
border-left-color: $warning;
background-color: lighten($warning, 36%);
color: darken($warning, 16%);
}
&.warning:before {
content: '\f1f1';
}
}
\ No newline at end of file
......
......@@ -20,6 +20,9 @@
&.disabled, &[disabled] {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
}
&:focus {
outline: 0;
}
}
#html-editor {
......
.page-list {
h3 {
margin: $-l 0 $-m 0;
margin: $-l 0 $-xs 0;
font-size: 1.666em;
}
a.chapter {
color: $color-chapter;
......@@ -8,7 +9,6 @@
.inset-list {
display: none;
overflow: hidden;
// padding-left: $-m;
margin-bottom: $-l;
}
h4 {
......@@ -338,6 +338,10 @@ ul.pagination {
padding-top: $-xs;
margin: 0;
}
> p.empty-text {
display: block;
font-size: $fs-m;
}
hr {
margin: 0;
}
......
......@@ -48,7 +48,7 @@
max-width: 100%;
height:auto;
}
h1, h2, h3, h4, h5, h6 {
h1, h2, h3, h4, h5, h6, pre {
clear: left;
}
hr {
......
......@@ -3,7 +3,7 @@
*/
h1 {
font-size: 3.625em;
font-size: 3.425em;
line-height: 1.22222222em;
margin-top: 0.48888889em;
margin-bottom: 0.48888889em;
......@@ -33,10 +33,10 @@ h1, h2, h3, h4 {
display: block;
color: #555;
.subheader {
display: block;
//display: block;
font-size: 0.5em;
line-height: 1em;
color: lighten($text-dark, 16%);
color: lighten($text-dark, 32%);
}
}
......@@ -225,6 +225,15 @@ p.secondary, p .secondary, span.secondary, .text-secondary {
color: $color-chapter;
}
}
.faded .text-book:hover {
color: $color-book !important;
}
.faded .text-chapter:hover {
color: $color-chapter !important;
}
.faded .text-page:hover {
color: $color-page !important;
}
span.highlight {
//background-color: rgba($primary, 0.2);
......
......@@ -38,6 +38,7 @@ $primary-dark: #0288D1;
$secondary: #e27b41;
$positive: #52A256;
$negative: #E84F4F;
$info: $primary;
$warning: $secondary;
$primary-faded: rgba(21, 101, 192, 0.15);
......
......@@ -207,3 +207,59 @@ $btt-size: 40px;
color: #EEE;
}
}
.entity-selector {
border: 1px solid #DDD;
border-radius: 3px;
overflow: hidden;
font-size: 0.8em;
input[type="text"] {
width: 100%;
display: block;
border-radius: 0;
border: 0;
border-bottom: 1px solid #DDD;
font-size: 16px;
padding: $-s $-m;
}
.entity-list {
overflow-y: scroll;
height: 400px;
background-color: #EEEEEE;
}
.loading {
height: 400px;
padding-top: $-l;
}
.entity-list > p {
text-align: center;
padding-top: $-l;
font-size: 1.333em;
}
.entity-list > div {
padding-left: $-m;
padding-right: $-m;
background-color: #FFF;
transition: all ease-in-out 120ms;
cursor: pointer;
}
}
.entity-list-item.selected {
h3, i, p ,a, span {
color: #EEE;
}
}
......
......@@ -4,7 +4,7 @@ return [
/**
* Activity text strings.
* Is used for all the text within activity logs.
* Is used for all the text within activity logs & notifications.
*/
// Pages
......@@ -16,6 +16,7 @@ return [
'page_delete_notification' => 'Page Successfully Deleted',
'page_restore' => 'restored page',
'page_restore_notification' => 'Page Successfully Restored',
'page_move' => 'moved page',
// Chapters
'chapter_create' => 'created chapter',
......@@ -24,6 +25,7 @@ return [
'chapter_update_notification' => 'Chapter Successfully Updated',
'chapter_delete' => 'deleted chapter',
'chapter_delete_notification' => 'Chapter Successfully Deleted',
'chapter_move' => 'moved chapter',
// Books
'book_create' => 'created book',
......
<div class="book">
<div class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}">
<h3 class="text-book"><a class="text-book" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i>{{$book->name}}</a></h3>
@if(isset($book->searchSnippet))
<p class="text-muted">{!! $book->searchSnippet !!}</p>
......
<div class="chapter">
<div class="chapter entity-list-item" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
<h3>
@if (isset($showPath) && $showPath)
<a href="{{ $chapter->book->getUrl() }}" class="text-book">
<i class="zmdi zmdi-book"></i>{{ $chapter->book->name }}
</a>
<span class="text-muted">&nbsp;&nbsp;&raquo;&nbsp;&nbsp;</span>
@endif
<a href="{{ $chapter->getUrl() }}" class="text-chapter">
<i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }}
</a>
......
@extends('base')
@section('content')
<div class="faded-small toolbar">
<div class="container">
<div class="row">
<div class="col-sm-12 faded">
<div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
<span class="sep">&raquo;</span>
<a href="{{$chapter->getUrl()}}" class="text-chapter text-button"><i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->getShortName() }}</a>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<h1>Move Chapter <small class="subheader">{{$chapter->name}}</small></h1>
<form action="{{ $chapter->getUrl() }}/move" method="POST">
{!! csrf_field() !!}
<input type="hidden" name="_method" value="PUT">
@include('partials/entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book'])
<a href="{{ $chapter->getUrl() }}" class="button muted">Cancel</a>
<button type="submit" class="button pos">Move Chapter</button>
</form>
</div>
@stop
......@@ -2,15 +2,15 @@
@section('content')
<div class="faded-small toolbar" ng-non-bindable>
<div class="faded-small toolbar">
<div class="container">
<div class="row">
<div class="col-md-4 faded">
<div class="col-sm-8 faded" ng-non-bindable>
<div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
</div>
</div>
<div class="col-md-8 faded">
<div class="col-sm-4 faded">
<div class="action-buttons">
@if(userCan('page-create', $chapter))
<a href="{{$chapter->getUrl() . '/create-page'}}" class="text-pos text-button"><i class="zmdi zmdi-plus"></i>New Page</a>
......@@ -18,11 +18,21 @@
@if(userCan('chapter-update', $chapter))
<a href="{{$chapter->getUrl() . '/edit'}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a>
@endif
@if(userCan('chapter-update', $chapter) || userCan('restrictions-manage', $chapter) || userCan('chapter-delete', $chapter))
<div dropdown class="dropdown-container">
<a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
<ul>
@if(userCan('chapter-update', $chapter))
<li><a href="{{$chapter->getUrl() . '/move'}}" class="text-primary"><i class="zmdi zmdi-folder"></i>Move</a></li>
@endif
@if(userCan('restrictions-manage', $chapter))
<a href="{{$chapter->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
<li><a href="{{$chapter->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
@endif
@if(userCan('chapter-delete', $chapter))
<a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
<li><a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
@endif
</ul>
</div>
@endif
</div>
</div>
......
......@@ -34,18 +34,30 @@
@else
<h3>Recent Books</h3>
@endif
@include('partials/entity-list', ['entities' => $recents, 'style' => 'compact'])
@include('partials/entity-list', [
'entities' => $recents,
'style' => 'compact',
'emptyText' => $signedIn ? 'You have not viewed any pages' : 'No books have been created'
])
</div>
<div class="col-sm-4">
<h3><a class="no-color" href="/pages/recently-created">Recently Created Pages</a></h3>
<div id="recently-created-pages">
@include('partials/entity-list', ['entities' => $recentlyCreatedPages, 'style' => 'compact'])
@include('partials/entity-list', [
'entities' => $recentlyCreatedPages,
'style' => 'compact',
'emptyText' => 'No pages have been recently created'
])
</div>
<h3><a class="no-color" href="/pages/recently-updated">Recently Updated Pages</a></h3>
<div id="recently-updated-pages">
@include('partials/entity-list', ['entities' => $recentlyUpdatedPages, 'style' => 'compact'])
@include('partials/entity-list', [
'entities' => $recentlyUpdatedPages,
'style' => 'compact',
'emptyText' => 'No pages have been recently updated'
])
</div>
</div>
......
......@@ -10,12 +10,12 @@
<h4>Page Tags</h4>
<div class="padded tags">
<p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
<table class="no-style" autosuggestions style="width: 100%;">
<table class="no-style" tag-autosuggestions style="width: 100%;">
<tbody ui-sortable="sortOptions" ng-model="tags" >
<tr ng-repeat="tag in tags track by $index">
<td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
<td><input autosuggest="/ajax/tags/suggest/names" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
<td><input autosuggest="/ajax/tags/suggest/values" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
<td><input autosuggest="/ajax/tags/suggest/names" autosuggest-type="name" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
<td><input autosuggest="/ajax/tags/suggest/values" autosuggest-type="value" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
<td width="10" ng-show="tags.length != 1" class="text-center text-neg" style="padding: 0;" ng-click="removeTag(tag)"><i class="zmdi zmdi-close"></i></td>
</tr>
</tbody>
......
......@@ -61,7 +61,7 @@
<button class="text-button" type="button" data-action="insertImage"><i class="zmdi zmdi-image"></i>Insert Image</button>
</div>
</div>
<textarea markdown-input md-change="editorChange" md-model="editContent" name="markdown" rows="5"
<textarea markdown-input md-change="editorChange" id="markdown-editor-input" md-model="editContent" name="markdown" rows="5"
@if($errors->has('markdown')) class="neg" @endif>@if(isset($model) || old('markdown')){{htmlspecialchars( old('markdown') ? old('markdown') : ($model->markdown === '' ? $model->html : $model->markdown))}}@endif</textarea>
</div>
......
<div class="page {{$page->draft ? 'draft' : ''}}">
<div class="page {{$page->draft ? 'draft' : ''}} entity-list-item" data-entity-type="page" data-entity-id="{{$page->id}}">
<h3>
<a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a>
</h3>
......@@ -11,11 +11,11 @@
@if(isset($style) && $style === 'detailed')
<div class="row meta text-muted text-small">
<div class="col-md-4">
<div class="col-md-6">
Created {{$page->created_at->diffForHumans()}} @if($page->createdBy)by {{$page->createdBy->name}}@endif <br>
Last updated {{ $page->updated_at->diffForHumans() }} @if($page->updatedBy)by {{$page->updatedBy->name}} @endif
</div>
<div class="col-md-8">
<div class="col-md-6">
<a class="text-book" href="{{ $page->book->getUrl() }}"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName(30) }}</a>
<br>
@if($page->chapter)
......
@extends('base')
@section('content')
<div class="faded-small toolbar">
<div class="container">
<div class="row">
<div class="col-sm-12 faded">
<div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
@if($page->hasChapter())
<span class="sep">&raquo;</span>
<a href="{{ $page->chapter->getUrl() }}" class="text-chapter text-button">
<i class="zmdi zmdi-collection-bookmark"></i>
{{$page->chapter->getShortName()}}
</a>
@endif
<span class="sep">&raquo;</span>
<a href="{{$page->getUrl()}}" class="text-page text-button"><i class="zmdi zmdi-file-text"></i>{{ $page->getShortName() }}</a>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<h1>Move Page <small class="subheader">{{$page->name}}</small></h1>
<form action="{{ $page->getUrl() }}/move" method="POST">
{!! csrf_field() !!}
<input type="hidden" name="_method" value="PUT">
@include('partials/entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter'])
<a href="{{ $page->getUrl() }}" class="button muted">Cancel</a>
<button type="submit" class="button pos">Move Page</button>
</form>
</div>
@stop
......@@ -28,15 +28,26 @@
</ul>
</span>
@if(userCan('page-update', $page))
<a href="{{$page->getUrl()}}/revisions" class="text-primary text-button"><i class="zmdi zmdi-replay"></i>Revisions</a>
<a href="{{$page->getUrl()}}/edit" class="text-primary text-button" ><i class="zmdi zmdi-edit"></i>Edit</a>
@endif
@if(userCan('page-update', $page) || userCan('restrictions-manage', $page) || userCan('page-delete', $page))
<div dropdown class="dropdown-container">
<a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
<ul>
@if(userCan('page-update', $page))
<li><a href="{{$page->getUrl()}}/move" class="text-primary" ><i class="zmdi zmdi-folder"></i>Move</a></li>
<li><a href="{{$page->getUrl()}}/revisions" class="text-primary"><i class="zmdi zmdi-replay"></i>Revisions</a></li>
@endif
@if(userCan('restrictions-manage', $page))
<a href="{{$page->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
<li><a href="{{$page->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
@endif
@if(userCan('page-delete', $page))
<a href="{{$page->getUrl()}}/delete" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
<li><a href="{{$page->getUrl()}}/delete" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
@endif
</ul>
</div>
@endif
</div>
</div>
</div>
......
{{--Requires an entity to be passed with the name $entity--}}
@if(count($activity) > 0)
<div class="activity-list">
@foreach($activity as $activityItem)
......@@ -10,5 +8,5 @@
@endforeach
</div>
@else
<p class="text-muted">New activity will show up here.</p>
<p class="text-muted">No activity to show</p>
@endif
\ No newline at end of file
......
@if(Setting::get('app-color'))
<style>
<style>
header, #back-to-top, .primary-background {
background-color: {{ Setting::get('app-color') }};
background-color: {{ Setting::get('app-color') }} !important;
}
.faded-small, .primary-background-light {
background-color: {{ Setting::get('app-color-light') }};
......@@ -18,5 +17,4 @@
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
color: {{ Setting::get('app-color') }};
}
</style>
@endif
\ No newline at end of file
</style>
\ No newline at end of file
......
......@@ -16,8 +16,8 @@
@endforeach
@else
<p class="text-muted">
No items available
<p class="text-muted empty-text">
{{ $emptyText or 'No items available' }}
</p>
@endif
</div>
\ No newline at end of file
......
<div class="form-group">
<div entity-selector class="entity-selector {{$selectorSize or ''}}" entity-types="{{ $entityTypes or 'book,chapter,page' }}">
<input type="hidden" entity-selector-input name="{{$name}}" value="">
<input type="text" placeholder="Search" ng-model="search" ng-model-options="{debounce: 200}" ng-change="searchEntities()">
<div class="text-center loading" ng-show="loading">@include('partials/loading-icon')</div>
<div ng-show="!loading" ng-bind-html="entityResults"></div>
</div>
</div>
\ No newline at end of file
<div class="notification anim pos" @if(!Session::has('success')) style="display:none;" @endif>
<i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(Session::get('success'))) !!}</span>
<div class="notification anim pos" @if(!session()->has('success')) style="display:none;" @endif>
<i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(session()->get('success'))) !!}</span>
</div>
<div class="notification anim warning stopped" @if(!Session::has('warning')) style="display:none;" @endif>
<i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(Session::get('warning'))) !!}</span>
<div class="notification anim warning stopped" @if(!session()->has('warning')) style="display:none;" @endif>
<i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(session()->get('warning'))) !!}</span>
</div>
<div class="notification anim neg stopped" @if(!Session::has('error')) style="display:none;" @endif>
<i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(Session::get('error'))) !!}</span>
<div class="notification anim neg stopped" @if(!session()->has('error')) style="display:none;" @endif>
<i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(session()->get('error'))) !!}</span>
</div>
......
<div class="entity-list @if(isset($style)){{ $style }}@endif" ng-non-bindable>
@if(count($entities) > 0)
@foreach($entities as $index => $entity)
@if($entity->isA('page'))
@include('pages/list-item', ['page' => $entity])
@elseif($entity->isA('book'))
@include('books/list-item', ['book' => $entity])
@elseif($entity->isA('chapter'))
@include('chapters/list-item', ['chapter' => $entity, 'hidePages' => true, 'showPath' => true])
@endif
@if($index !== count($entities) - 1)
<hr>
@endif
@endforeach
@else
<p class="text-muted">
No items available
</p>
@endif
</div>
\ No newline at end of file
......@@ -82,4 +82,14 @@ class EntitySearchTest extends TestCase
$this->asAdmin()->visit('/search/books?term=' . $book->name)
->see('Book Search Results')->see('.entity-list', $book->name);
}
public function test_ajax_entity_search()
{
$page = \BookStack\Page::all()->last();
$notVisitedPage = \BookStack\Page::first();
$this->visit($page->getUrl());
$this->asAdmin()->visit('/ajax/search/entities?term=' . $page->name)->see('.entity-list', $page->name);
$this->asAdmin()->visit('/ajax/search/entities?types=book&term=' . $page->name)->dontSee('.entity-list', $page->name);
$this->asAdmin()->visit('/ajax/search/entities')->see('.entity-list', $page->name)->dontSee($notVisitedPage->name);
}
}
......
......@@ -22,4 +22,47 @@ class SortTest extends TestCase
->dontSee($draft->name);
}
public function test_page_move()
{
$page = \BookStack\Page::first();
$currentBook = $page->book;
$newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first();
$this->asAdmin()->visit($page->getUrl() . '/move')
->see('Move Page')->see($page->name)
->type('book:' . $newBook->id, 'entity_selection')->press('Move Page');
$page = \BookStack\Page::find($page->id);
$this->seePageIs($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
$this->visit($newBook->getUrl())
->seeInNthElement('.activity-list-item', 0, 'moved page')
->seeInNthElement('.activity-list-item', 0, $page->name);
}
public function test_chapter_move()
{
$chapter = \BookStack\Chapter::first();
$currentBook = $chapter->book;
$pageToCheck = $chapter->pages->first();
$newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first();
$this->asAdmin()->visit($chapter->getUrl() . '/move')
->see('Move Chapter')->see($chapter->name)
->type('book:' . $newBook->id, 'entity_selection')->press('Move Chapter');
$chapter = \BookStack\Chapter::find($chapter->id);
$this->seePageIs($chapter->getUrl());
$this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book');
$this->visit($newBook->getUrl())
->seeInNthElement('.activity-list-item', 0, 'moved chapter')
->seeInNthElement('.activity-list-item', 0, $chapter->name);
$pageToCheck = \BookStack\Page::find($pageToCheck->id);
$this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book');
$this->visit($pageToCheck->getUrl())
->see($newBook->name);
}
}
\ No newline at end of file
......
<?php
class ImageTest extends TestCase
{
/**
* Get a test image that can be uploaded
* @param $fileName
* @return \Illuminate\Http\UploadedFile
*/
protected function getTestImage($fileName)
{
return new \Illuminate\Http\UploadedFile(base_path('tests/test-image.jpg'), $fileName, 'image/jpeg', 5238);
}
/**
* Get the path for a test image.
* @param $type
* @param $fileName
* @return string
*/
protected function getTestImagePath($type, $fileName)
{
return '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/' . $fileName;
}
/**
* Uploads an image with the given name.
* @param $name
* @param int $uploadedTo
* @return string
*/
protected function uploadImage($name, $uploadedTo = 0)
{
$file = $this->getTestImage($name);
$this->call('POST', '/images/gallery/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
return $this->getTestImagePath('gallery', $name);
}
/**
* Delete an uploaded image.
* @param $relPath
*/
protected function deleteImage($relPath)
{
unlink(public_path($relPath));
}
public function test_image_upload()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$admin = $this->getAdmin();
$imageName = 'first-image.jpg';
$relPath = $this->uploadImage($imageName, $page->id);
$this->assertResponseOk();
$this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image exists');
$this->seeInDatabase('images', [
'url' => $relPath,
'type' => 'gallery',
'uploaded_to' => $page->id,
'path' => $relPath,
'created_by' => $admin->id,
'updated_by' => $admin->id,
'name' => $imageName
]);
$this->deleteImage($relPath);
}
public function test_image_delete()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$imageName = 'first-image.jpg';
$relPath = $this->uploadImage($imageName, $page->id);
$image = \BookStack\Image::first();
$this->call('DELETE', '/images/' . $image->id);
$this->assertResponseOk();
$this->dontSeeInDatabase('images', [
'url' => $relPath,
'type' => 'gallery'
]);
$this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has been deleted');
}
}
\ No newline at end of file
......@@ -39,11 +39,19 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
*/
public function asAdmin()
{
return $this->actingAs($this->getAdmin());
}
/**
* Get the current admin user.
* @return mixed
*/
public function getAdmin() {
if($this->admin === null) {
$adminRole = \BookStack\Role::getRole('admin');
$this->admin = $adminRole->users->first();
}
return $this->actingAs($this->admin);
return $this->admin;
}
/**
......