mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Merge pull request #4721 from BookStackApp/default-templates
Continued: Default book templates
This commit is contained in:
commit
4896c4047f
@ -14,11 +14,9 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookApiController extends ApiController
|
||||
{
|
||||
protected BookRepo $bookRepo;
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
public function __construct(
|
||||
protected BookRepo $bookRepo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -58,7 +56,9 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
|
||||
$book = Book::visible()
|
||||
->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])
|
||||
->findOrFail($id);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
@ -116,12 +116,14 @@ class BookApiController extends ApiController
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@ -24,15 +24,11 @@ use Throwable;
|
||||
|
||||
class BookController extends Controller
|
||||
{
|
||||
protected BookRepo $bookRepo;
|
||||
protected ShelfContext $shelfContext;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->shelfContext = $entityContextManager;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
public function __construct(
|
||||
protected ShelfContext $shelfContext,
|
||||
protected BookRepo $bookRepo,
|
||||
protected ReferenceFetcher $referenceFetcher
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -96,10 +92,11 @@ class BookController extends Controller
|
||||
{
|
||||
$this->checkPermission('book-create-all');
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$bookshelf = null;
|
||||
@ -170,10 +167,11 @@ class BookController extends Controller
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
if ($request->has('image_reset')) {
|
||||
|
@ -5,6 +5,7 @@ namespace BookStack\Entities\Controllers;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
@ -71,7 +72,6 @@ class PageController extends Controller
|
||||
$page = $this->pageRepo->getNewDraftPage($parent);
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $request->get('name'),
|
||||
'html' => '',
|
||||
]);
|
||||
|
||||
return redirect($page->getUrl('/edit'));
|
||||
@ -259,11 +259,13 @@ class PageController extends Controller
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-delete', $page);
|
||||
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
|
||||
$usedAsTemplate = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
|
||||
|
||||
return view('pages.delete', [
|
||||
'book' => $page->book,
|
||||
'page' => $page,
|
||||
'current' => $page,
|
||||
'usedAsTemplate' => $usedAsTemplate,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -277,11 +279,13 @@ class PageController extends Controller
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
|
||||
$usedAsTemplate = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
|
||||
|
||||
return view('pages.delete', [
|
||||
'book' => $page->book,
|
||||
'page' => $page,
|
||||
'current' => $page,
|
||||
'usedAsTemplate' => $usedAsTemplate,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -15,11 +15,13 @@ use Illuminate\Support\Collection;
|
||||
*
|
||||
* @property string $description
|
||||
* @property int $image_id
|
||||
* @property ?int $default_template_id
|
||||
* @property Image|null $cover
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||
* @property ?Page $defaultTemplate
|
||||
*/
|
||||
class Book extends Entity implements HasCoverImage
|
||||
{
|
||||
@ -71,6 +73,14 @@ class Book extends Entity implements HasCoverImage
|
||||
return 'cover_book';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Page that is used as default template for newly created pages within this Book.
|
||||
*/
|
||||
public function defaultTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'default_template_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages within this book.
|
||||
*/
|
||||
|
@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
@ -17,18 +18,11 @@ use Illuminate\Support\Collection;
|
||||
|
||||
class BookRepo
|
||||
{
|
||||
protected $baseRepo;
|
||||
protected $tagRepo;
|
||||
protected $imageRepo;
|
||||
|
||||
/**
|
||||
* BookRepo constructor.
|
||||
*/
|
||||
public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
|
||||
{
|
||||
$this->baseRepo = $baseRepo;
|
||||
$this->tagRepo = $tagRepo;
|
||||
$this->imageRepo = $imageRepo;
|
||||
public function __construct(
|
||||
protected BaseRepo $baseRepo,
|
||||
protected TagRepo $tagRepo,
|
||||
protected ImageRepo $imageRepo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -92,6 +86,7 @@ class BookRepo
|
||||
$book = new Book();
|
||||
$this->baseRepo->create($book, $input);
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
|
||||
$this->updateBookDefaultTemplate($book, intval($input['default_template_id'] ?? null));
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
return $book;
|
||||
@ -104,6 +99,10 @@ class BookRepo
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
|
||||
if (array_key_exists('default_template_id', $input)) {
|
||||
$this->updateBookDefaultTemplate($book, intval($input['default_template_id']));
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $input)) {
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
|
||||
}
|
||||
@ -113,6 +112,33 @@ class BookRepo
|
||||
return $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the default page template used for this book.
|
||||
* Checks that, if changing, the provided value is a valid template and the user
|
||||
* has visibility of the provided page template id.
|
||||
*/
|
||||
protected function updateBookDefaultTemplate(Book $book, int $templateId): void
|
||||
{
|
||||
$changing = $templateId !== intval($book->default_template_id);
|
||||
if (!$changing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($templateId === 0) {
|
||||
$book->default_template_id = null;
|
||||
$book->save();
|
||||
return;
|
||||
}
|
||||
|
||||
$templateExists = Page::query()->visible()
|
||||
->where('template', '=', true)
|
||||
->where('id', '=', $templateId)
|
||||
->exists();
|
||||
|
||||
$book->default_template_id = $templateExists ? $templateId : null;
|
||||
$book->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given book's cover image, or clear it.
|
||||
*
|
||||
|
@ -136,6 +136,14 @@ class PageRepo
|
||||
$page->book_id = $parent->id;
|
||||
}
|
||||
|
||||
$defaultTemplate = $page->book->defaultTemplate;
|
||||
if ($defaultTemplate && userCan('view', $defaultTemplate)) {
|
||||
$page->forceFill([
|
||||
'html' => $defaultTemplate->html,
|
||||
'markdown' => $defaultTemplate->markdown,
|
||||
]);
|
||||
}
|
||||
|
||||
$page->save();
|
||||
$page->refresh()->rebuildPermissions();
|
||||
|
||||
|
@ -202,6 +202,10 @@ class TrashCan
|
||||
$attachmentService->deleteFile($attachment);
|
||||
}
|
||||
|
||||
// Remove book template usages
|
||||
Book::query()->where('default_template_id', '=', $page->id)
|
||||
->update(['default_template_id' => null]);
|
||||
|
||||
$page->forceDelete();
|
||||
|
||||
return 1;
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Search;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\Popular;
|
||||
use BookStack\Entities\Tools\SiblingFetcher;
|
||||
use BookStack\Http\Controller;
|
||||
@ -82,6 +83,32 @@ class SearchController extends Controller
|
||||
return view('search.parts.entity-selector-list', ['entities' => $entities, 'permission' => $permission]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a list of templates to choose from.
|
||||
*/
|
||||
public function templatesForSelector(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('term', false);
|
||||
|
||||
if ($searchTerm !== false) {
|
||||
$searchOptions = SearchOptions::fromString($searchTerm);
|
||||
$searchOptions->setFilter('is_template');
|
||||
$entities = $this->searchRunner->searchEntities($searchOptions, 'page', 1, 20)['results'];
|
||||
} else {
|
||||
$entities = Page::visible()
|
||||
->where('template', '=', true)
|
||||
->where('draft', '=', false)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->take(20)
|
||||
->get(Page::$listAttributes);
|
||||
}
|
||||
|
||||
return view('search.parts.entity-selector-list', [
|
||||
'entities' => $entities,
|
||||
'permission' => 'view'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a list of entities and return a partial HTML response of matching entities
|
||||
* to be used as a result preview suggestion list for global system searches.
|
||||
|
@ -170,6 +170,14 @@ class SearchOptions
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of a specific filter in the search options.
|
||||
*/
|
||||
public function setFilter(string $filterName, string $filterValue = ''): void
|
||||
{
|
||||
$this->filters[$filterName] = $filterValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode this instance to a search string.
|
||||
*/
|
||||
|
@ -58,7 +58,7 @@ class SearchRunner
|
||||
$entityTypesToSearch = $entityTypes;
|
||||
|
||||
if ($entityType !== 'all') {
|
||||
$entityTypesToSearch = $entityType;
|
||||
$entityTypesToSearch = [$entityType];
|
||||
} elseif (isset($searchOpts->filters['type'])) {
|
||||
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
|
||||
}
|
||||
@ -469,6 +469,13 @@ class SearchRunner
|
||||
});
|
||||
}
|
||||
|
||||
protected function filterIsTemplate(EloquentBuilder $query, Entity $model, $input)
|
||||
{
|
||||
if ($model instanceof Page) {
|
||||
$query->where('template', '=', true);
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
|
||||
{
|
||||
$functionName = Str::camel('sort_by_' . $input);
|
||||
|
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddDefaultTemplateToBooks extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('books', function (Blueprint $table) {
|
||||
$table->integer('default_template_id')->nullable()->default(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('books', function (Blueprint $table) {
|
||||
$table->dropColumn('default_template_id');
|
||||
});
|
||||
}
|
||||
}
|
@ -1,4 +1,9 @@
|
||||
{
|
||||
"name": "My own book",
|
||||
"description": "This is my own little book"
|
||||
"description": "This is my own little book",
|
||||
"default_template_id": 12,
|
||||
"tags": [
|
||||
{"name": "Category", "value": "Top Content"},
|
||||
{"name": "Rating", "value": "Highest"}
|
||||
]
|
||||
}
|
@ -1,4 +1,8 @@
|
||||
{
|
||||
"name": "My updated book",
|
||||
"description": "This is my book with updated details"
|
||||
"description": "This is my book with updated details",
|
||||
"default_template_id": 12,
|
||||
"tags": [
|
||||
{"name": "Subject", "value": "Updates"}
|
||||
]
|
||||
}
|
@ -6,6 +6,7 @@
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1,
|
||||
"default_template_id": 12,
|
||||
"updated_at": "2020-01-12T14:05:11.000000Z",
|
||||
"created_at": "2020-01-12T14:05:11.000000Z"
|
||||
}
|
@ -20,6 +20,7 @@
|
||||
"name": "Admin",
|
||||
"slug": "admin"
|
||||
},
|
||||
"default_template_id": null,
|
||||
"contents": [
|
||||
{
|
||||
"id": 50,
|
||||
|
@ -1,11 +1,12 @@
|
||||
{
|
||||
"id": 16,
|
||||
"name": "My own book",
|
||||
"slug": "my-own-book",
|
||||
"description": "This is my own little book - updated",
|
||||
"name": "My updated book",
|
||||
"slug": "my-updated-book",
|
||||
"description": "This is my book with updated details",
|
||||
"created_at": "2020-01-12T14:09:59.000000Z",
|
||||
"updated_at": "2020-01-12T14:16:10.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1
|
||||
"owned_by": 1,
|
||||
"default_template_id": 12
|
||||
}
|
@ -132,6 +132,9 @@ return [
|
||||
'books_edit_named' => 'Edit Book :bookName',
|
||||
'books_form_book_name' => 'Book Name',
|
||||
'books_save' => 'Save Book',
|
||||
'books_default_template' => 'Default Page Template',
|
||||
'books_default_template_explain' => 'Assign a page template that will be used as the default content for all new pages in this book. Keep in mind this will only be used if the page creator has view access to those chosen template page.',
|
||||
'books_default_template_select' => 'Select a template page',
|
||||
'books_permissions' => 'Book Permissions',
|
||||
'books_permissions_updated' => 'Book Permissions Updated',
|
||||
'books_empty_contents' => 'No pages or chapters have been created for this book.',
|
||||
@ -204,6 +207,7 @@ return [
|
||||
'pages_delete_draft' => 'Delete Draft Page',
|
||||
'pages_delete_success' => 'Page deleted',
|
||||
'pages_delete_draft_success' => 'Draft page deleted',
|
||||
'pages_delete_warning_template' => 'This page is in active use as a book default page template. These books will no longer have a default page template assigned after this page is deleted.',
|
||||
'pages_delete_confirm' => 'Are you sure you want to delete this page?',
|
||||
'pages_delete_draft_confirm' => 'Are you sure you want to delete this draft page?',
|
||||
'pages_editing_named' => 'Editing Page :pageName',
|
||||
|
@ -10,6 +10,7 @@ export class EntitySelector extends Component {
|
||||
this.elem = this.$el;
|
||||
this.entityTypes = this.$opts.entityTypes || 'page,book,chapter';
|
||||
this.entityPermission = this.$opts.entityPermission || 'view';
|
||||
this.searchEndpoint = this.$opts.searchEndpoint || '/search/entity-selector';
|
||||
|
||||
this.input = this.$refs.input;
|
||||
this.searchInput = this.$refs.search;
|
||||
@ -18,7 +19,6 @@ export class EntitySelector extends Component {
|
||||
|
||||
this.search = '';
|
||||
this.lastClick = 0;
|
||||
this.selectedItemData = null;
|
||||
|
||||
this.setupListeners();
|
||||
this.showLoading();
|
||||
@ -110,7 +110,7 @@ export class EntitySelector extends Component {
|
||||
}
|
||||
|
||||
searchUrl() {
|
||||
return `/search/entity-selector?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
|
||||
return `${this.searchEndpoint}?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
|
||||
}
|
||||
|
||||
searchEntities(searchTerm) {
|
||||
@ -153,7 +153,6 @@ export class EntitySelector extends Component {
|
||||
|
||||
if (isSelected) {
|
||||
item.classList.add('selected');
|
||||
this.selectedItemData = data;
|
||||
} else {
|
||||
window.$events.emit('entity-select-change', null);
|
||||
}
|
||||
@ -177,7 +176,6 @@ export class EntitySelector extends Component {
|
||||
for (const selectedElem of selected) {
|
||||
selectedElem.classList.remove('selected', 'primary-background');
|
||||
}
|
||||
this.selectedItemData = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {Component} from './component';
|
||||
|
||||
function toggleElem(elem, show) {
|
||||
elem.style.display = show ? null : 'none';
|
||||
elem.toggleAttribute('hidden', !show);
|
||||
}
|
||||
|
||||
export class PagePicker extends Component {
|
||||
@ -21,6 +21,7 @@ export class PagePicker extends Component {
|
||||
setupListeners() {
|
||||
this.selectButton.addEventListener('click', this.showPopup.bind(this));
|
||||
this.display.parentElement.addEventListener('click', this.showPopup.bind(this));
|
||||
this.display.addEventListener('click', e => e.stopPropagation());
|
||||
|
||||
this.resetButton.addEventListener('click', () => {
|
||||
this.setValue('', '');
|
||||
|
@ -434,7 +434,7 @@ input[type=color] {
|
||||
&.flexible input {
|
||||
width: 100%;
|
||||
}
|
||||
.search-box-cancel {
|
||||
button.search-box-cancel {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
@ -266,10 +266,18 @@ body.flexbox {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.fill-height {
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.height-fill {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.height-auto {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.float {
|
||||
float: left;
|
||||
&.right {
|
||||
|
@ -27,8 +27,10 @@
|
||||
|
||||
<main class="content-wrap card">
|
||||
<h1 class="list-heading">{{ trans('entities.books_create') }}</h1>
|
||||
<form action="{{ isset($bookshelf) ? $bookshelf->getUrl('/create-book') : url('/books') }}" method="POST" enctype="multipart/form-data">
|
||||
@include('books.parts.form', ['returnLocation' => isset($bookshelf) ? $bookshelf->getUrl() : url('/books')])
|
||||
<form action="{{ $bookshelf?->getUrl('/create-book') ?? url('/books') }}" method="POST" enctype="multipart/form-data">
|
||||
@include('books.parts.form', [
|
||||
'returnLocation' => $bookshelf?->getUrl() ?? url('/books')
|
||||
])
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
@ -18,7 +18,10 @@
|
||||
<h1 class="list-heading">{{ trans('entities.books_edit') }}</h1>
|
||||
<form action="{{ $book->getUrl() }}" method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
@include('books.parts.form', ['model' => $book, 'returnLocation' => $book->getUrl()])
|
||||
@include('books.parts.form', [
|
||||
'model' => $book,
|
||||
'returnLocation' => $book->getUrl()
|
||||
])
|
||||
</form>
|
||||
</main>
|
||||
|
||||
|
@ -35,7 +35,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group collapsible" component="collapsible" id="template-control">
|
||||
<button refs="collapsible@trigger" type="button" class="collapse-title text-primary" aria-expanded="false">
|
||||
<label for="template-manager">{{ trans('entities.books_default_template') }}</label>
|
||||
</button>
|
||||
<div refs="collapsible@content" class="collapse-content">
|
||||
<div class="flex-container-row gap-l justify-space-between pb-xs wrap">
|
||||
<p class="text-muted small my-none min-width-xs flex">
|
||||
{{ trans('entities.books_default_template_explain') }}
|
||||
</p>
|
||||
|
||||
<div class="min-width-m">
|
||||
@include('form.page-picker', [
|
||||
'name' => 'default_template_id',
|
||||
'placeholder' => trans('entities.books_default_template_select'),
|
||||
'value' => $book->default_template_id ?? null,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ $returnLocation }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<button type="submit" class="button">{{ trans('entities.books_save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates'])
|
@ -7,7 +7,7 @@
|
||||
</div>
|
||||
@include('entities.selector', ['name' => 'entity-selector'])
|
||||
<div class="popup-footer">
|
||||
<button refs="entity-selector-popup@select" type="button" disabled="true" class="button">{{ trans('common.select') }}</button>
|
||||
<button refs="entity-selector-popup@select" type="button" disabled class="button">{{ trans('common.select') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,7 +3,8 @@
|
||||
refs="entity-selector-popup@selector"
|
||||
class="entity-selector {{$selectorSize ?? ''}}"
|
||||
option:entity-selector:entity-types="{{ $entityTypes ?? 'book,chapter,page' }}"
|
||||
option:entity-selector:entity-permission="{{ $entityPermission ?? 'view' }}">
|
||||
option:entity-selector:entity-permission="{{ $entityPermission ?? 'view' }}"
|
||||
option:entity-selector:search-endpoint="{{ $selectorEndpoint ?? '/search/entity-selector' }}">
|
||||
<input refs="entity-selector@input" type="hidden" name="{{$name}}" value="">
|
||||
<input refs="entity-selector@search" type="text" placeholder="{{ trans('common.search') }}" @if($autofocus ?? false) autofocus @endif>
|
||||
<div class="text-center loading" refs="entity-selector@loading">@include('common.loading-icon')</div>
|
||||
|
13
resources/views/form/page-picker.blade.php
Normal file
13
resources/views/form/page-picker.blade.php
Normal file
@ -0,0 +1,13 @@
|
||||
|
||||
{{--Depends on entity selector popup--}}
|
||||
<div component="page-picker">
|
||||
<div class="input-base overflow-hidden height-auto">
|
||||
<span @if($value) hidden @endif refs="page-picker@default-display" class="text-muted italic">{{ $placeholder }}</span>
|
||||
<a @if(!$value) hidden @endif href="{{ url('/link/' . $value) }}" target="_blank" rel="noopener" class="text-page" refs="page-picker@display">#{{$value}}, {{$value ? \BookStack\Entities\Models\Page::query()->visible()->find($value)->name ?? '' : '' }}</a>
|
||||
</div>
|
||||
<br>
|
||||
<input refs="page-picker@input" type="hidden" value="{{$value}}" name="{{$name}}" id="{{$name}}">
|
||||
<button @if(!$value) hidden @endif type="button" refs="page-picker@reset-button" class="text-button">{{ trans('common.reset') }}</button>
|
||||
<span refs="page-picker@button-seperator" @if(!$value) hidden @endif class="sep">|</span>
|
||||
<button type="button" refs="page-picker@select-button" class="text-button">{{ trans('common.select') }}</button>
|
||||
</div>
|
@ -19,6 +19,9 @@
|
||||
<div class="card content-wrap auto-height">
|
||||
<h1 class="list-heading">{{ $page->draft ? trans('entities.pages_delete_draft') : trans('entities.pages_delete') }}</h1>
|
||||
|
||||
@if($usedAsTemplate)
|
||||
<p class="text-warn">{{ trans('entities.pages_delete_warning_template') }}</p>
|
||||
@endif
|
||||
|
||||
<div class="grid half v-center">
|
||||
<div>
|
||||
|
@ -4,7 +4,7 @@
|
||||
|
||||
@section('content')
|
||||
|
||||
<div id="main-content" class="flex-fill flex fill-height">
|
||||
<div id="main-content" class="flex-fill flex height-fill">
|
||||
<form action="{{ $page->getUrl() }}" autocomplete="off" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill">
|
||||
{{ csrf_field() }}
|
||||
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
<div class="form-group title-input">
|
||||
<label for="name">{{ trans('entities.pages_name') }}</label>
|
||||
@include('form.text', ['name' => 'name'])
|
||||
@include('form.text', ['name' => 'name', 'autofocus' => true])
|
||||
</div>
|
||||
|
||||
<div class="form-group text-right">
|
||||
|
@ -14,7 +14,7 @@
|
||||
<div class="search-box flexible mb-m" style="display: {{ count($templates) > 0 ? 'block' : 'none' }}">
|
||||
<input refs="template-manager@searchInput" type="text" name="template-search" placeholder="{{ trans('common.search') }}">
|
||||
<button refs="template-manager@searchButton" tabindex="-1" type="button">@icon('search')</button>
|
||||
<button refs="template-manager@searchCancel" class="search-box-cancel text-neg" type="button" style="display: none">@icon('close')</button>
|
||||
<button refs="template-manager@searchCancel" class="search-box-cancel text-neg" tabindex="-1" type="button" style="display: none">@icon('close')</button>
|
||||
</div>
|
||||
|
||||
<div refs="template-manager@list">
|
||||
|
@ -133,7 +133,7 @@
|
||||
</select>
|
||||
|
||||
<div refs="setting-homepage-control@page-picker-container" style="display: none;" class="mt-m">
|
||||
@include('settings.parts.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')])
|
||||
@include('form.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,13 +0,0 @@
|
||||
|
||||
{{--Depends on entity selector popup--}}
|
||||
<div component="page-picker">
|
||||
<div class="input-base">
|
||||
<span @if($value) style="display: none" @endif refs="page-picker@default-display" class="text-muted italic">{{ $placeholder }}</span>
|
||||
<a @if(!$value) style="display: none" @endif href="{{ url('/link/' . $value) }}" target="_blank" rel="noopener" class="text-page" refs="page-picker@display">#{{$value}}, {{$value ? \BookStack\Entities\Models\Page::find($value)->name : '' }}</a>
|
||||
</div>
|
||||
<br>
|
||||
<input refs="page-picker@input" type="hidden" value="{{$value}}" name="{{$name}}" id="{{$name}}">
|
||||
<button @if(!$value) style="display: none" @endif type="button" refs="page-picker@reset-button" class="text-button">{{ trans('common.reset') }}</button>
|
||||
<span refs="page-picker@button-seperator" @if(!$value) style="display: none" @endif class="sep">|</span>
|
||||
<button type="button" refs="page-picker@select-button" class="text-button">{{ trans('common.select') }}</button>
|
||||
</div>
|
@ -182,6 +182,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/search/chapter/{bookId}', [SearchController::class, 'searchChapter']);
|
||||
Route::get('/search/entity/siblings', [SearchController::class, 'searchSiblings']);
|
||||
Route::get('/search/entity-selector', [SearchController::class, 'searchForSelector']);
|
||||
Route::get('/search/entity-selector-templates', [SearchController::class, 'templatesForSelector']);
|
||||
Route::get('/search/suggest', [SearchController::class, 'searchSuggestions']);
|
||||
|
||||
// User Search
|
||||
|
@ -31,13 +31,16 @@ class BooksApiTest extends TestCase
|
||||
public function test_create_endpoint()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$details = [
|
||||
'name' => 'My API book',
|
||||
'description' => 'A book created via the API',
|
||||
'default_template_id' => $templatePage->id,
|
||||
];
|
||||
|
||||
$resp = $this->postJson($this->baseEndpoint, $details);
|
||||
$resp->assertStatus(200);
|
||||
|
||||
$newItem = Book::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
|
||||
$resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
|
||||
$this->assertActivityExists('book_create', $newItem);
|
||||
@ -83,6 +86,7 @@ class BooksApiTest extends TestCase
|
||||
'owned_by' => [
|
||||
'name' => $book->ownedBy->name,
|
||||
],
|
||||
'default_template_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -121,9 +125,11 @@ class BooksApiTest extends TestCase
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$book = $this->entities->book();
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$details = [
|
||||
'name' => 'My updated API book',
|
||||
'description' => 'A book created via the API',
|
||||
'default_template_id' => $templatePage->id,
|
||||
];
|
||||
|
||||
$resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details);
|
||||
|
185
tests/Entity/BookDefaultTemplateTest.php
Normal file
185
tests/Entity/BookDefaultTemplateTest.php
Normal file
@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Entity;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BookDefaultTemplateTest extends TestCase
|
||||
{
|
||||
public function test_creating_book_with_default_template()
|
||||
{
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$details = [
|
||||
'name' => 'My book with default template',
|
||||
'default_template_id' => $templatePage->id,
|
||||
];
|
||||
|
||||
$this->asEditor()->post('/books', $details);
|
||||
$this->assertDatabaseHas('books', $details);
|
||||
}
|
||||
|
||||
public function test_updating_book_with_default_template()
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
$templatePage = $this->entities->templatePage();
|
||||
|
||||
$this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
|
||||
|
||||
$this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => '']);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
|
||||
}
|
||||
|
||||
public function test_default_template_cannot_be_set_if_not_a_template()
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
$page = $this->entities->page();
|
||||
$this->assertFalse($page->template);
|
||||
|
||||
$this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
|
||||
}
|
||||
|
||||
public function test_default_template_cannot_be_set_if_not_have_access()
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$this->permissions->disableEntityInheritedPermissions($templatePage);
|
||||
|
||||
$this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
|
||||
}
|
||||
|
||||
public function test_inaccessible_default_template_can_be_set_if_unchanged()
|
||||
{
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$book = $this->bookUsingDefaultTemplate($templatePage);
|
||||
$this->permissions->disableEntityInheritedPermissions($templatePage);
|
||||
|
||||
$this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
|
||||
}
|
||||
|
||||
public function test_default_page_template_option_shows_on_book_form()
|
||||
{
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$book = $this->bookUsingDefaultTemplate($templatePage);
|
||||
|
||||
$resp = $this->asEditor()->get($book->getUrl('/edit'));
|
||||
$this->withHtml($resp)->assertElementExists('input[name="default_template_id"][value="' . $templatePage->id . '"]');
|
||||
}
|
||||
|
||||
public function test_default_page_template_option_only_shows_template_name_if_visible()
|
||||
{
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$book = $this->bookUsingDefaultTemplate($templatePage);
|
||||
|
||||
$resp = $this->asEditor()->get($book->getUrl('/edit'));
|
||||
$this->withHtml($resp)->assertElementContains('#template-control a.text-page', "#{$templatePage->id}, {$templatePage->name}");
|
||||
|
||||
$this->permissions->disableEntityInheritedPermissions($templatePage);
|
||||
|
||||
$resp = $this->asEditor()->get($book->getUrl('/edit'));
|
||||
$this->withHtml($resp)->assertElementNotContains('#template-control a.text-page', "#{$templatePage->id}, {$templatePage->name}");
|
||||
$this->withHtml($resp)->assertElementContains('#template-control a.text-page', "#{$templatePage->id}");
|
||||
}
|
||||
|
||||
public function test_creating_book_page_uses_default_template()
|
||||
{
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$templatePage->forceFill(['html' => '<p>My template page</p>', 'markdown' => '# My template page'])->save();
|
||||
$book = $this->bookUsingDefaultTemplate($templatePage);
|
||||
|
||||
$this->asEditor()->get($book->getUrl('/create-page'));
|
||||
$latestPage = $book->pages()
|
||||
->where('draft', '=', true)
|
||||
->where('template', '=', false)
|
||||
->latest()->first();
|
||||
|
||||
$this->assertEquals('<p>My template page</p>', $latestPage->html);
|
||||
$this->assertEquals('# My template page', $latestPage->markdown);
|
||||
}
|
||||
|
||||
public function test_creating_chapter_page_uses_default_template()
|
||||
{
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$templatePage->forceFill(['html' => '<p>My template page in chapter</p>', 'markdown' => '# My template page in chapter'])->save();
|
||||
$book = $this->bookUsingDefaultTemplate($templatePage);
|
||||
$chapter = $book->chapters()->first();
|
||||
|
||||
$this->asEditor()->get($chapter->getUrl('/create-page'));
|
||||
$latestPage = $chapter->pages()
|
||||
->where('draft', '=', true)
|
||||
->where('template', '=', false)
|
||||
->latest()->first();
|
||||
|
||||
$this->assertEquals('<p>My template page in chapter</p>', $latestPage->html);
|
||||
$this->assertEquals('# My template page in chapter', $latestPage->markdown);
|
||||
}
|
||||
|
||||
public function test_creating_book_page_as_guest_uses_default_template()
|
||||
{
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$templatePage->forceFill(['html' => '<p>My template page</p>', 'markdown' => '# My template page'])->save();
|
||||
$book = $this->bookUsingDefaultTemplate($templatePage);
|
||||
$guest = $this->users->guest();
|
||||
|
||||
$this->permissions->makeAppPublic();
|
||||
$this->permissions->grantUserRolePermissions($guest, ['page-create-all', 'page-update-all']);
|
||||
|
||||
$resp = $this->post($book->getUrl('/create-guest-page'), [
|
||||
'name' => 'My guest page with template'
|
||||
]);
|
||||
$latestPage = $book->pages()
|
||||
->where('draft', '=', false)
|
||||
->where('template', '=', false)
|
||||
->where('created_by', '=', $guest->id)
|
||||
->latest()->first();
|
||||
|
||||
$this->assertEquals('<p>My template page</p>', $latestPage->html);
|
||||
$this->assertEquals('# My template page', $latestPage->markdown);
|
||||
}
|
||||
|
||||
public function test_creating_book_page_does_not_use_template_if_not_visible()
|
||||
{
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$templatePage->forceFill(['html' => '<p>My template page</p>', 'markdown' => '# My template page'])->save();
|
||||
$book = $this->bookUsingDefaultTemplate($templatePage);
|
||||
$this->permissions->disableEntityInheritedPermissions($templatePage);
|
||||
|
||||
$this->asEditor()->get($book->getUrl('/create-page'));
|
||||
$latestPage = $book->pages()
|
||||
->where('draft', '=', true)
|
||||
->where('template', '=', false)
|
||||
->latest()->first();
|
||||
|
||||
$this->assertEquals('', $latestPage->html);
|
||||
$this->assertEquals('', $latestPage->markdown);
|
||||
}
|
||||
|
||||
public function test_template_page_delete_removes_book_template_usage()
|
||||
{
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$book = $this->bookUsingDefaultTemplate($templatePage);
|
||||
|
||||
$book->refresh();
|
||||
$this->assertEquals($templatePage->id, $book->default_template_id);
|
||||
|
||||
$this->asEditor()->delete($templatePage->getUrl());
|
||||
$this->asAdmin()->post('/settings/recycle-bin/empty');
|
||||
|
||||
$book->refresh();
|
||||
$this->assertEquals(null, $book->default_template_id);
|
||||
}
|
||||
|
||||
protected function bookUsingDefaultTemplate(Page $page): Book
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
$book->default_template_id = $page->id;
|
||||
$book->save();
|
||||
|
||||
return $book;
|
||||
}
|
||||
}
|
@ -252,6 +252,39 @@ class EntitySearchTest extends TestCase
|
||||
$this->withHtml($resp)->assertElementContains($baseSelector, "You don't have the required permissions to select this item");
|
||||
}
|
||||
|
||||
public function test_entity_template_selector_search()
|
||||
{
|
||||
$templatePage = $this->entities->newPage(['name' => 'Template search test', 'html' => 'template test']);
|
||||
$templatePage->template = true;
|
||||
$templatePage->save();
|
||||
|
||||
$nonTemplatePage = $this->entities->newPage(['name' => 'Nontemplate page', 'html' => 'nontemplate', 'template' => false]);
|
||||
|
||||
// Visit both to make popular
|
||||
$this->asEditor()->get($templatePage->getUrl());
|
||||
$this->get($nonTemplatePage->getUrl());
|
||||
|
||||
$normalSearch = $this->get('/search/entity-selector-templates?term=test');
|
||||
$normalSearch->assertSee($templatePage->name);
|
||||
$normalSearch->assertDontSee($nonTemplatePage->name);
|
||||
|
||||
$normalSearch = $this->get('/search/entity-selector-templates?term=beans');
|
||||
$normalSearch->assertDontSee($templatePage->name);
|
||||
$normalSearch->assertDontSee($nonTemplatePage->name);
|
||||
|
||||
$defaultListTest = $this->get('/search/entity-selector-templates');
|
||||
$defaultListTest->assertSee($templatePage->name);
|
||||
$defaultListTest->assertDontSee($nonTemplatePage->name);
|
||||
|
||||
$this->permissions->disableEntityInheritedPermissions($templatePage);
|
||||
|
||||
$normalSearch = $this->get('/search/entity-selector-templates?term=test');
|
||||
$normalSearch->assertDontSee($templatePage->name);
|
||||
|
||||
$defaultListTest = $this->get('/search/entity-selector-templates');
|
||||
$defaultListTest->assertDontSee($templatePage->name);
|
||||
}
|
||||
|
||||
public function test_sibling_search_for_pages()
|
||||
{
|
||||
$chapter = $this->entities->chapterHasPages();
|
||||
|
@ -53,6 +53,15 @@ class EntityProvider
|
||||
return $this->page(fn(Builder $query) => $query->where('chapter_id', '=', 0));
|
||||
}
|
||||
|
||||
public function templatePage(): Page
|
||||
{
|
||||
$page = $this->page();
|
||||
$page->template = true;
|
||||
$page->save();
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an un-fetched chapter from the system.
|
||||
*/
|
||||
|
@ -5,16 +5,21 @@ namespace Tests\Helpers;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Permissions\Models\EntityPermission;
|
||||
use BookStack\Permissions\Models\RolePermission;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Users\Models\Role;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class PermissionsProvider
|
||||
{
|
||||
protected UserRoleProvider $userRoleProvider;
|
||||
public function __construct(
|
||||
protected UserRoleProvider $userRoleProvider
|
||||
) {
|
||||
}
|
||||
|
||||
public function __construct(UserRoleProvider $userRoleProvider)
|
||||
public function makeAppPublic(): void
|
||||
{
|
||||
$this->userRoleProvider = $userRoleProvider;
|
||||
$settings = app(SettingService::class);
|
||||
$settings->put('app-public', 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user