diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index cb67184a085..41ff11ddec3 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -14,11 +14,9 @@ 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 @@ public function create(Request $request) */ 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 @@ protected function rules(): array '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 55d28c6847e..faa5788938e 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -24,15 +24,11 @@ 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 @@ public function store(Request $request, string $shelfSlug = null) { $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 @@ public function update(Request $request, string $slug) $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 4d8c7e809f9..0a3e76daa42 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -5,6 +5,7 @@ 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 @@ public function createAsGuest(Request $request, string $bookSlug, string $chapte $page = $this->pageRepo->getNewDraftPage($parent); $this->pageRepo->publishDraft($page, [ 'name' => $request->get('name'), - 'html' => '', ]); return redirect($page->getUrl('/edit')); @@ -259,11 +259,13 @@ public function showDelete(string $bookSlug, string $pageSlug) $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 @@ public function showDeleteDraft(string $bookSlug, int $pageId) $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 f54a0bf2d6a..ee9a7f44722 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -15,11 +15,13 @@ * * @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 @@ public function coverImageTypeKey(): string 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 737caa70bb4..03e1118b122 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -5,6 +5,7 @@ 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 @@ 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 @@ public function create(array $input): Book $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 @@ public function update(Book $book, array $input): Book { $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 @@ public function update(Book $book, array $input): Book 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 dbd4a47d243..9a183469b4a 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -136,6 +136,14 @@ public function getNewDraftPage(Entity $parent) $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 08276230c40..b2510398541 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -202,6 +202,10 @@ protected function destroyPage(Page $page): int $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 09a67f2b5ca..6cf12a57920 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 @@ public function searchForSelector(Request $request) 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 d38fc8d5751..fffa03db094 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -170,6 +170,14 @@ protected static function parseStandardTermString(string $termString): array 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 fc36cb816c8..aac9d10005b 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -58,7 +58,7 @@ public function searchEntities(SearchOptions $searchOpts, string $entityType = ' $entityTypesToSearch = $entityTypes; if ($entityType !== 'all') { - $entityTypesToSearch = $entityType; + $entityTypesToSearch = [$entityType]; } elseif (isset($searchOpts->filters['type'])) { $entityTypesToSearch = explode('|', $searchOpts->filters['type']); } @@ -469,6 +469,13 @@ protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, $i }); } + 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 00000000000..c23bebc2e63 --- /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 4a66266196b..2a38dba8392 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 fc67d5fccdd..c026b7b4943 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 12a3e9e9fcd..77387912535 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 3744445d050..21e1829b8eb 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 7d3d6735e1e..f69677c4ad7 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 cfb5aae1a78..354eee42e79 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -132,6 +132,9 @@ '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 @@ '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 9cda3587401..b12eeb402ba 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 130972fdd63..9bb0bee04a3 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 2257e8000d0..cd5d929f4b5 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 d157ffdc36d..6c78419d86c 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 eead4191c34..318abfcf137 100644 --- a/resources/views/books/create.blade.php +++ b/resources/views/books/create.blade.php @@ -27,8 +27,10 @@

{{ trans('entities.books_create') }}

-
- @include('books.parts.form', ['returnLocation' => isset($bookshelf) ? $bookshelf->getUrl() : url('/books')]) + + @include('books.parts.form', [ + 'returnLocation' => $bookshelf?->getUrl() ?? url('/books') + ])
diff --git a/resources/views/books/edit.blade.php b/resources/views/books/edit.blade.php index 180500e0a04..6efd1caea92 100644 --- a/resources/views/books/edit.blade.php +++ b/resources/views/books/edit.blade.php @@ -18,7 +18,10 @@

{{ trans('entities.books_edit') }}

- @include('books.parts.form', ['model' => $book, 'returnLocation' => $book->getUrl()]) + @include('books.parts.form', [ + 'model' => $book, + 'returnLocation' => $book->getUrl() + ])
diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php index 56d385c9e2a..e22be619d66 100644 --- a/resources/views/books/parts/form.blade.php +++ b/resources/views/books/parts/form.blade.php @@ -35,7 +35,31 @@ +
+ +
+
+

+ {{ trans('entities.books_default_template_explain') }} +

+ +
+ @include('form.page-picker', [ + 'name' => 'default_template_id', + 'placeholder' => trans('entities.books_default_template_select'), + 'value' => $book->default_template_id ?? null, + ]) +
+
+ +
+
+
{{ trans('common.cancel') }} -
\ No newline at end of file + + +@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates']) \ No newline at end of file diff --git a/resources/views/entities/selector-popup.blade.php b/resources/views/entities/selector-popup.blade.php index c896b50b523..d4c941e9a33 100644 --- a/resources/views/entities/selector-popup.blade.php +++ b/resources/views/entities/selector-popup.blade.php @@ -7,7 +7,7 @@ @include('entities.selector', ['name' => 'entity-selector']) diff --git a/resources/views/entities/selector.blade.php b/resources/views/entities/selector.blade.php index a9f5b932cc2..c1280cfb2f7 100644 --- a/resources/views/entities/selector.blade.php +++ b/resources/views/entities/selector.blade.php @@ -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' }}">
@include('common.loading-icon')
diff --git a/resources/views/form/page-picker.blade.php b/resources/views/form/page-picker.blade.php new file mode 100644 index 00000000000..d9810d575df --- /dev/null +++ b/resources/views/form/page-picker.blade.php @@ -0,0 +1,13 @@ + +{{--Depends on entity selector popup--}} +
+
+ + +
+
+ + + + +
\ No newline at end of file diff --git a/resources/views/pages/delete.blade.php b/resources/views/pages/delete.blade.php index 39cd07bbb1c..a9c4b73ad74 100644 --- a/resources/views/pages/delete.blade.php +++ b/resources/views/pages/delete.blade.php @@ -19,6 +19,9 @@

{{ $page->draft ? trans('entities.pages_delete_draft') : trans('entities.pages_delete') }}

+ @if($usedAsTemplate) +

{{ trans('entities.pages_delete_warning_template') }}

+ @endif
diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index 841b275037b..58fb5f355ea 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -4,7 +4,7 @@ @section('content') -
+
{{ csrf_field() }} diff --git a/resources/views/pages/guest-create.blade.php b/resources/views/pages/guest-create.blade.php index d6e1cae446a..11970b8b6e3 100644 --- a/resources/views/pages/guest-create.blade.php +++ b/resources/views/pages/guest-create.blade.php @@ -22,7 +22,7 @@
- @include('form.text', ['name' => 'name']) + @include('form.text', ['name' => 'name', 'autofocus' => true])
diff --git a/resources/views/pages/parts/template-manager.blade.php b/resources/views/pages/parts/template-manager.blade.php index c209626cdbf..ee4467552e4 100644 --- a/resources/views/pages/parts/template-manager.blade.php +++ b/resources/views/pages/parts/template-manager.blade.php @@ -14,7 +14,7 @@
diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index be99cc25476..7112ebcff64 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -133,7 +133,7 @@ class="p-m">
diff --git a/resources/views/settings/parts/page-picker.blade.php b/resources/views/settings/parts/page-picker.blade.php deleted file mode 100644 index d599a19ab6a..00000000000 --- a/resources/views/settings/parts/page-picker.blade.php +++ /dev/null @@ -1,13 +0,0 @@ - -{{--Depends on entity selector popup--}} -
- -
- - - - -
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 8fc90ee54c4..4620cd08bc3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -182,6 +182,7 @@ 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 diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 326304d6f11..c648faaf2f4 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -31,13 +31,16 @@ public function test_index_endpoint_returns_expected_book() 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 @@ public function test_read_endpoint() 'owned_by' => [ 'name' => $book->ownedBy->name, ], + 'default_template_id' => null, ]); } @@ -121,9 +125,11 @@ public function test_update_endpoint() { $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); diff --git a/tests/Entity/BookDefaultTemplateTest.php b/tests/Entity/BookDefaultTemplateTest.php new file mode 100644 index 00000000000..d4cd5b2c3a9 --- /dev/null +++ b/tests/Entity/BookDefaultTemplateTest.php @@ -0,0 +1,185 @@ +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' => '

My template page

', '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('

My template page

', $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' => '

My template page in chapter

', '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('

My template page in chapter

', $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' => '

My template page

', '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('

My template page

', $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' => '

My template page

', '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; + } +} diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index fbb47226e6a..9b77a32ab80 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -252,6 +252,39 @@ public function test_entity_selector_search_reflects_items_without_permission() $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(); diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php index 3cb8c44d32c..98206342145 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -53,6 +53,15 @@ public function pageNotWithinChapter(): Page 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. */ diff --git a/tests/Helpers/PermissionsProvider.php b/tests/Helpers/PermissionsProvider.php index 512f43fb6cc..cb036fe97c7 100644 --- a/tests/Helpers/PermissionsProvider.php +++ b/tests/Helpers/PermissionsProvider.php @@ -5,16 +5,21 @@ 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'); } /**