]> BookStack Code Mirror - bookstack/commitdiff
Default templates: Added page picker and working forms
authorDan Brown <redacted>
Mon, 11 Dec 2023 15:55:43 +0000 (15:55 +0000)
committerDan Brown <redacted>
Mon, 11 Dec 2023 15:58:27 +0000 (15:58 +0000)
- Adapted existing page picker to be usable elsewhere.
- Added endpoint for getting templates for entity picker.
- Added search template filter to support above.
- Updated book save handling to check/validate submitted template.
  - Allows non-visible pages to flow through the save process, if not
    being changed.
- Updated page deletes to handle removal of default usage on books.
- Tweaked wording and form styles to suit.
- Updated migration to explicity reflect default value.

19 files changed:
app/Entities/Controllers/PageController.php
app/Entities/Models/Book.php
app/Entities/Repos/BookRepo.php
app/Entities/Tools/TrashCan.php
app/Search/SearchController.php
app/Search/SearchOptions.php
app/Search/SearchRunner.php
database/migrations/2023_12_02_104541_add_default_template_to_books.php
lang/en/entities.php
resources/js/components/entity-selector.js
resources/sass/_layout.scss
resources/views/books/parts/form.blade.php
resources/views/books/parts/template-selector.blade.php [deleted file]
resources/views/entities/selector-popup.blade.php
resources/views/entities/selector.blade.php
resources/views/form/page-picker.blade.php [moved from resources/views/settings/parts/page-picker.blade.php with 86% similarity]
resources/views/pages/delete.blade.php
resources/views/settings/customization.blade.php
routes/web.php

index 11f19f72f111e06ade95a8505546a23f186c46b0..d929341232a001619b74c4ee9069711f5d74afbf 100644 (file)
@@ -279,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', '=', $page->id)->count() > 0;
 
         return view('pages.delete', [
             'book'    => $page->book,
             'page'    => $page,
             'current' => $page,
+            'usedAsTemplate' => $usedAsTemplate,
         ]);
     }
 
index 19aba0525573007e1f8f6f399d93147b79a30966..faae276a5c08bd8389768b3cf9d6fa04d82bc6ca 100644 (file)
@@ -15,6 +15,7 @@ use Illuminate\Support\Collection;
  *
  * @property string                                   $description
  * @property int                                      $image_id
+ * @property ?int                                     $default_template
  * @property Image|null                               $cover
  * @property \Illuminate\Database\Eloquent\Collection $chapters
  * @property \Illuminate\Database\Eloquent\Collection $pages
index 737caa70bb47c23623fb238c2e70fb9df03280bb..b46218fe0ee39ef6d2460d653b782b9ca142758b 100644 (file)
@@ -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
+    ) {
     }
 
     /**
@@ -104,6 +98,10 @@ class BookRepo
     {
         $this->baseRepo->update($book, $input);
 
+        if (array_key_exists('default_template', $input)) {
+            $this->updateBookDefaultTemplate($book, intval($input['default_template']));
+        }
+
         if (array_key_exists('image', $input)) {
             $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
         }
@@ -113,6 +111,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);
+        if (!$changing) {
+            return;
+        }
+
+        if ($templateId === 0) {
+            $book->default_template = null;
+            $book->save();
+            return;
+        }
+
+        $templateExists = Page::query()->visible()
+            ->where('template', '=', true)
+            ->where('id', '=', $templateId)
+            ->exists();
+
+        $book->default_template = $templateExists ? $templateId : null;
+        $book->save();
+    }
+
     /**
      * Update the given book's cover image, or clear it.
      *
index 08276230c4059eabc5427350ede5df693ba0a486..b0c452456a06d541a97c7e301ed1d7d74b91ef13 100644 (file)
@@ -202,6 +202,10 @@ class TrashCan
             $attachmentService->deleteFile($attachment);
         }
 
+        // Remove book template usages
+        Book::query()->where('default_template', '=', $page->id)
+            ->update(['default_template' => null]);
+
         $page->forceDelete();
 
         return 1;
index 09a67f2b5cae6b351cb70c6ec1a29de39d4207a0..6cf12a57920631ecebdff6fdddf01938d80f1ac6 100644 (file)
@@ -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.
index d38fc8d5751f3017fe39f1eced968b580e754d18..fffa03db094e717121247ee39f945e6a9786d884 100644 (file)
@@ -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.
      */
index fc36cb816c8e90219f57f3cec344b559fe6c2134..aac9d10005b64f23768961356890e21ea37cde35 100644 (file)
@@ -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);
index 755f83b5c0b7fff3989723689be37f146d0e69a2..913361dcbcf746d55c994a28dc1979e30575b155 100644 (file)
@@ -14,7 +14,7 @@ class AddDefaultTemplateToBooks extends Migration
     public function up()
     {
         Schema::table('books', function (Blueprint $table) {
-            $table->integer('default_template')->nullable();
+            $table->integer('default_template')->nullable()->default(null);
         });
     }
 
index ee612b7ba4f1e6bd5234999b9828466297b5db81..354eee42e7983a1304cc21385264be0ac98eea47 100644 (file)
@@ -133,7 +133,8 @@ return [
     'books_form_book_name' => 'Book Name',
     'books_save' => 'Save Book',
     'books_default_template' => 'Default Page Template',
-    'books_default_template_explain' => 'Assign a default template that will be used 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.',
+    '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.',
@@ -206,7 +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 page default template assigned after this page is 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',
index 9cda35874019e4291951712de8ab96e719c454ec..b12eeb402ba14768d8b4ae573b9864ad4b15c844 100644 (file)
@@ -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;
     }
 
 }
index d157ffdc36d372bbb374ba9a5543f80da0584d6b..94a36ecba59d6813c119159c4c841acdd25b10d6 100644 (file)
@@ -266,6 +266,10 @@ body.flexbox {
   display: none !important;
 }
 
+.overflow-hidden {
+  overflow: hidden;
+}
+
 .fill-height {
   height: 100%;
 }
index a6b0eade27b6b70b530f27049531ac5a3ded2122..b16468a09c49456bcfa6945c95612f4e4fc1deb4 100644 (file)
         <label for="template-manager">{{ trans('entities.books_default_template') }}</label>
     </button>
     <div refs="collapsible@content" class="collapse-content">
-        @include('books.parts.template-selector', ['entity' => $book ?? null, 'templates' => []])
+        <div class="flex-container-row items-center gap-m justify-space-between pt-s pb-xs">
+            <p class="text-muted small my-none">
+                {{ trans('entities.books_default_template_explain') }}
+            </p>
+
+
+            @include('form.page-picker', [
+                'name' => 'default_template',
+                'placeholder' => trans('entities.books_default_template_select'),
+                'value' => $book?->default_template ?? null,
+            ])
+        </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>
\ No newline at end of file
+</div>
+
+@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates'])
\ No newline at end of file
diff --git a/resources/views/books/parts/template-selector.blade.php b/resources/views/books/parts/template-selector.blade.php
deleted file mode 100644 (file)
index 90c5e42..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-<p class="text-muted small">
-    {{ trans('entities.books_default_template_explain') }}
-</p>
-
-<select name="default_template" id="default_template">
-    <option value="">---</option>
-    @foreach ($templates as $template)
-        <option @if(isset($entity) && $entity->default_template === $template->id) selected @endif value="{{ $template->id }}">{{ $template->name }}</option>
-    @endforeach
-</select>
-
-
-@include('settings.parts.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')])
\ No newline at end of file
index c896b50b5234ab25bddcca53ecc7408112ef8aa4..d4c941e9a3358fa82e392d81639148c5a3019244 100644 (file)
@@ -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>
index a9f5b932cc2358b692a78e10a7165d9f9040bd3c..c1280cfb2f7934b7c26ccb1d4060a08ea0198521 100644 (file)
@@ -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>
similarity index 86%
rename from resources/views/settings/parts/page-picker.blade.php
rename to resources/views/form/page-picker.blade.php
index d599a19ab6a1cf87d76fecd1ad0c3221f7f3691c..90ce75676585a4c2e9b526ab79d154d748562ff5 100644 (file)
@@ -1,9 +1,9 @@
 
 {{--Depends on entity selector popup--}}
 <div component="page-picker">
-    <div class="input-base">
+    <div class="input-base overflow-hidden">
         <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>
+        <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::query()->visible()->find($value)->name ?? '' : '' }}</a>
     </div>
     <br>
     <input refs="page-picker@input" type="hidden" value="{{$value}}" name="{{$name}}" id="{{$name}}">
index 40125dfe2f473da00d1413b8a6a2ba415897dd8a..a9c4b73ad7457e49a18603c7803ab287fd66ac23 100644 (file)
@@ -20,7 +20,7 @@
             <h1 class="list-heading">{{ $page->draft ? trans('entities.pages_delete_draft') : trans('entities.pages_delete') }}</h1>
 
             @if($usedAsTemplate)
-                <p>{{ trans('entities.pages_delete_warning_template') }}</p>
+                <p class="text-warn">{{ trans('entities.pages_delete_warning_template') }}</p>
             @endif
 
             <div class="grid half v-center">
index be99cc2547606a93fff6a0ac430dadb953f73d4e..7112ebcff64d44fb9f5daee60da23a5f9a8cd4c2 100644 (file)
                     </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>
index 8fc90ee54c4f77f3ea93040be0407cce302fc597..4620cd08bc370deeafa5a3546deead32b9a53972 100644 (file)
@@ -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
Morty Proxy This is a proxified and sanitized view of the page, visit original site.