diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php
index cb67184a0..41ff11dde 100644
--- a/app/Entities/Controllers/BookApiController.php
+++ b/app/Entities/Controllers/BookApiController.php
@@ -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'],
],
];
}
diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php
index 55d28c684..faa578893 100644
--- a/app/Entities/Controllers/BookController.php
+++ b/app/Entities/Controllers/BookController.php
@@ -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')) {
diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php
index 4d8c7e809..0a3e76daa 100644
--- a/app/Entities/Controllers/PageController.php
+++ b/app/Entities/Controllers/PageController.php
@@ -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,
]);
}
diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php
index f54a0bf2d..ee9a7f447 100644
--- a/app/Entities/Models/Book.php
+++ b/app/Entities/Models/Book.php
@@ -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.
*/
diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php
index 737caa70b..03e1118b1 100644
--- a/app/Entities/Repos/BookRepo.php
+++ b/app/Entities/Repos/BookRepo.php
@@ -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.
*
diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php
index dbd4a47d2..9a183469b 100644
--- a/app/Entities/Repos/PageRepo.php
+++ b/app/Entities/Repos/PageRepo.php
@@ -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();
diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php
index 08276230c..b25103985 100644
--- a/app/Entities/Tools/TrashCan.php
+++ b/app/Entities/Tools/TrashCan.php
@@ -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;
diff --git a/app/Search/SearchController.php b/app/Search/SearchController.php
index 09a67f2b5..6cf12a579 100644
--- a/app/Search/SearchController.php
+++ b/app/Search/SearchController.php
@@ -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.
diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php
index d38fc8d57..fffa03db0 100644
--- a/app/Search/SearchOptions.php
+++ b/app/Search/SearchOptions.php
@@ -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.
*/
diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php
index fc36cb816..aac9d1000 100644
--- a/app/Search/SearchRunner.php
+++ b/app/Search/SearchRunner.php
@@ -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);
diff --git a/database/migrations/2023_12_02_104541_add_default_template_to_books.php b/database/migrations/2023_12_02_104541_add_default_template_to_books.php
new file mode 100644
index 000000000..c23bebc2e
--- /dev/null
+++ b/database/migrations/2023_12_02_104541_add_default_template_to_books.php
@@ -0,0 +1,32 @@
+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');
+ });
+ }
+}
diff --git a/dev/api/requests/books-create.json b/dev/api/requests/books-create.json
index 4a6626619..2a38dba83 100644
--- a/dev/api/requests/books-create.json
+++ b/dev/api/requests/books-create.json
@@ -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"}
+ ]
}
\ No newline at end of file
diff --git a/dev/api/requests/books-update.json b/dev/api/requests/books-update.json
index fc67d5fcc..c026b7b49 100644
--- a/dev/api/requests/books-update.json
+++ b/dev/api/requests/books-update.json
@@ -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"}
+ ]
}
\ No newline at end of file
diff --git a/dev/api/responses/books-create.json b/dev/api/responses/books-create.json
index 12a3e9e9f..773879125 100644
--- a/dev/api/responses/books-create.json
+++ b/dev/api/responses/books-create.json
@@ -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"
}
\ No newline at end of file
diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json
index 3744445d0..21e1829b8 100644
--- a/dev/api/responses/books-read.json
+++ b/dev/api/responses/books-read.json
@@ -20,6 +20,7 @@
"name": "Admin",
"slug": "admin"
},
+ "default_template_id": null,
"contents": [
{
"id": 50,
diff --git a/dev/api/responses/books-update.json b/dev/api/responses/books-update.json
index 7d3d6735e..f69677c4a 100644
--- a/dev/api/responses/books-update.json
+++ b/dev/api/responses/books-update.json
@@ -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
}
\ No newline at end of file
diff --git a/lang/en/entities.php b/lang/en/entities.php
index cfb5aae1a..354eee42e 100644
--- a/lang/en/entities.php
+++ b/lang/en/entities.php
@@ -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',
diff --git a/resources/js/components/entity-selector.js b/resources/js/components/entity-selector.js
index 9cda35874..b12eeb402 100644
--- a/resources/js/components/entity-selector.js
+++ b/resources/js/components/entity-selector.js
@@ -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;
}
}
diff --git a/resources/js/components/page-picker.js b/resources/js/components/page-picker.js
index 130972fdd..9bb0bee04 100644
--- a/resources/js/components/page-picker.js
+++ b/resources/js/components/page-picker.js
@@ -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('', '');
diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss
index 2257e8000..cd5d929f4 100644
--- a/resources/sass/_forms.scss
+++ b/resources/sass/_forms.scss
@@ -434,7 +434,7 @@ input[type=color] {
&.flexible input {
width: 100%;
}
- .search-box-cancel {
+ button.search-box-cancel {
left: auto;
right: 0;
}
diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss
index d157ffdc3..6c78419d8 100644
--- a/resources/sass/_layout.scss
+++ b/resources/sass/_layout.scss
@@ -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 {
diff --git a/resources/views/books/create.blade.php b/resources/views/books/create.blade.php
index eead4191c..318abfcf1 100644
--- a/resources/views/books/create.blade.php
+++ b/resources/views/books/create.blade.php
@@ -27,8 +27,10 @@
{{ trans('entities.books_create') }}
-
+ {{ trans('entities.books_default_template_explain') }} +
+ +{{ trans('entities.pages_delete_warning_template') }}
+ @endif