diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 6b004984f..adaa7a8d3 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -69,6 +69,10 @@ class PageRepo extends EntityRepo $this->tagRepo->saveTagsToEntity($page, $input['tags']); } + if (isset($input['template']) && userCan('templates-manage')) { + $page->template = ($input['template'] === 'true'); + } + // Update with new details $userId = user()->id; $page->fill($input); @@ -85,8 +89,9 @@ class PageRepo extends EntityRepo $this->userUpdatePageDraftsQuery($page, $userId)->delete(); // Save a revision after updating - if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) { - $this->savePageRevision($page, $input['summary']); + $summary = $input['summary'] ?? null; + if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $summary !== null) { + $this->savePageRevision($page, $summary); } $this->searchService->indexEntity($page); @@ -300,6 +305,10 @@ class PageRepo extends EntityRepo $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']); } + if (isset($input['template']) && userCan('templates-manage')) { + $draftPage->template = ($input['template'] === 'true'); + } + $draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id); $draftPage->html = $this->formatHtml($input['html']); $draftPage->text = $this->pageToPlainText($draftPage); diff --git a/database/migrations/2019_07_07_112515_add_template_support.php b/database/migrations/2019_07_07_112515_add_template_support.php new file mode 100644 index 000000000..a54508198 --- /dev/null +++ b/database/migrations/2019_07_07_112515_add_template_support.php @@ -0,0 +1,54 @@ +<?php + +use Carbon\Carbon; +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Migrations\Migration; + +class AddTemplateSupport extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::table('pages', function (Blueprint $table) { + $table->boolean('template')->default(false); + $table->index('template'); + }); + + // Create new templates-manage permission and assign to admin role + $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id; + $permissionId = DB::table('role_permissions')->insertGetId([ + 'name' => 'templates-manage', + 'display_name' => 'Manage Page Templates', + 'created_at' => Carbon::now()->toDateTimeString(), + 'updated_at' => Carbon::now()->toDateTimeString() + ]); + DB::table('permission_role')->insert([ + 'role_id' => $adminRoleId, + 'permission_id' => $permissionId + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('pages', function (Blueprint $table) { + $table->dropColumn('template'); + }); + + // Remove templates-manage permission + $templatesManagePermission = DB::table('role_permissions') + ->where('name', '=', 'templates_manage')->first(); + + DB::table('permission_role')->where('permission_id', '=', $templatesManagePermission->id)->delete(); + DB::table('role_permissions')->where('name', '=', 'templates_manage')->delete(); + } +} diff --git a/resources/assets/icons/template.svg b/resources/assets/icons/template.svg new file mode 100644 index 000000000..7c142124f --- /dev/null +++ b/resources/assets/icons/template.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm16-2h-7v2h7v7h2V4c0-1.1-.9-2-2-2zm0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4zM16.475 15.356h-8.95v-2.237h8.95zm0-4.475h-8.95V8.644h8.95z"/></svg> \ No newline at end of file diff --git a/resources/assets/sass/_pages.scss b/resources/assets/sass/_pages.scss index 39e93f5b1..7f2dad147 100755 --- a/resources/assets/sass/_pages.scss +++ b/resources/assets/sass/_pages.scss @@ -258,7 +258,7 @@ display: block; cursor: pointer; padding: $-s $-m; - font-size: 13.5px; + font-size: 16px; line-height: 1.6; border-bottom: 1px solid rgba(255, 255, 255, 0.3); } diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index f6df7e71b..1a5eca207 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -233,6 +233,7 @@ return [ ], 'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content', 'pages_specific' => 'Specific Page', + 'pages_is_template' => 'Page Template', // Editor Sidebar 'page_tags' => 'Page Tags', @@ -269,6 +270,9 @@ return [ 'attachments_file_uploaded' => 'File successfully uploaded', 'attachments_file_updated' => 'File successfully updated', 'attachments_link_attached' => 'Link successfully attached to page', + 'templates' => 'Templates', + 'templates_set_as_template' => 'Page is a template', + 'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.', // Profile View 'profile_user_for_x' => 'User for :time', diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 43b4d4740..3ef53a247 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -85,6 +85,7 @@ return [ 'role_manage_roles' => 'Manage roles & role permissions', 'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions', 'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages', + 'role_manage_page_templates' => 'Manage page templates', 'role_manage_settings' => 'Manage app settings', 'role_asset' => 'Asset Permissions', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', diff --git a/resources/views/pages/attachment-manager.blade.php b/resources/views/pages/attachment-manager.blade.php new file mode 100644 index 000000000..7b16c6b7b --- /dev/null +++ b/resources/views/pages/attachment-manager.blade.php @@ -0,0 +1,99 @@ +<div toolbox-tab-content="files" id="attachment-manager" page-id="{{ $page->id ?? 0 }}"> + + @exposeTranslations([ + 'entities.attachments_file_uploaded', + 'entities.attachments_file_updated', + 'entities.attachments_link_attached', + 'entities.attachments_updated_success', + 'errors.server_upload_limit', + 'components.image_upload_remove', + 'components.file_upload_timeout', + ]) + + <h4>{{ trans('entities.attachments') }}</h4> + <div class="px-l files"> + + <div id="file-list" v-show="!fileToEdit"> + <p class="text-muted small">{{ trans('entities.attachments_explain') }} <span class="text-warn">{{ trans('entities.attachments_explain_instant_save') }}</span></p> + + <div class="tab-container"> + <div class="nav-tabs"> + <div @click="tab = 'list'" :class="{selected: tab === 'list'}" class="tab-item">{{ trans('entities.attachments_items') }}</div> + <div @click="tab = 'file'" :class="{selected: tab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div> + <div @click="tab = 'link'" :class="{selected: tab === 'link'}" class="tab-item">{{ trans('entities.attachments_link') }}</div> + </div> + <div v-show="tab === 'list'"> + <draggable style="width: 100%;" :options="{handle: '.handle'}" @change="fileSortUpdate" :list="files" element="div"> + <div v-for="(file, index) in files" :key="file.id" class="card drag-card"> + <div class="handle">@icon('grip')</div> + <div class="py-s"> + <a :href="getFileUrl(file)" target="_blank" v-text="file.name"></a> + <div v-if="file.deleting"> + <span class="text-neg small">{{ trans('entities.attachments_delete_confirm') }}</span> + <br> + <span class="text-primary small" @click="file.deleting = false;">{{ trans('common.cancel') }}</span> + </div> + </div> + <div @click="startEdit(file)" class="drag-card-action text-center text-primary">@icon('edit')</div> + <div @click="deleteFile(file)" class="drag-card-action text-center text-neg">@icon('close')</div> + </div> + </draggable> + <p class="small text-muted" v-if="files.length === 0"> + {{ trans('entities.attachments_no_files') }} + </p> + </div> + <div v-show="tab === 'file'"> + <dropzone placeholder="{{ trans('entities.attachments_dropzone') }}" :upload-url="getUploadUrl()" :uploaded-to="pageId" @success="uploadSuccess"></dropzone> + </div> + <div v-show="tab === 'link'" @keypress.enter.prevent="attachNewLink(file)"> + <p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p> + <div class="form-group"> + <label for="attachment-via-link">{{ trans('entities.attachments_link_name') }}</label> + <input type="text" placeholder="{{ trans('entities.attachments_link_name') }}" v-model="file.name"> + <p class="small text-neg" v-for="error in errors.link.name" v-text="error"></p> + </div> + <div class="form-group"> + <label for="attachment-via-link">{{ trans('entities.attachments_link_url') }}</label> + <input type="text" placeholder="{{ trans('entities.attachments_link_url_hint') }}" v-model="file.link"> + <p class="small text-neg" v-for="error in errors.link.link" v-text="error"></p> + </div> + <button @click.prevent="attachNewLink(file)" class="button primary">{{ trans('entities.attach') }}</button> + + </div> + </div> + + </div> + + <div id="file-edit" v-if="fileToEdit" @keypress.enter.prevent="updateFile(fileToEdit)"> + <h5>{{ trans('entities.attachments_edit_file') }}</h5> + + <div class="form-group"> + <label for="attachment-name-edit">{{ trans('entities.attachments_edit_file_name') }}</label> + <input type="text" id="attachment-name-edit" placeholder="{{ trans('entities.attachments_edit_file_name') }}" v-model="fileToEdit.name"> + <p class="small text-neg" v-for="error in errors.edit.name" v-text="error"></p> + </div> + + <div class="tab-container"> + <div class="nav-tabs"> + <div @click="editTab = 'file'" :class="{selected: editTab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div> + <div @click="editTab = 'link'" :class="{selected: editTab === 'link'}" class="tab-item">{{ trans('entities.attachments_set_link') }}</div> + </div> + <div v-if="editTab === 'file'"> + <dropzone :upload-url="getUploadUrl(fileToEdit)" :uploaded-to="pageId" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" @success="uploadSuccessUpdate"></dropzone> + <br> + </div> + <div v-if="editTab === 'link'"> + <div class="form-group"> + <label for="attachment-link-edit">{{ trans('entities.attachments_link_url') }}</label> + <input type="text" id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" v-model="fileToEdit.link"> + <p class="small text-neg" v-for="error in errors.edit.link" v-text="error"></p> + </div> + </div> + </div> + + <button type="button" class="button outline" @click="cancelEdit">{{ trans('common.back') }}</button> + <button @click.enter.prevent="updateFile(fileToEdit)" class="button primary">{{ trans('common.save') }}</button> + </div> + + </div> +</div> \ No newline at end of file diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index 15f5d5d96..b2cda7e10 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -16,7 +16,7 @@ <input type="hidden" name="_method" value="PUT"> @endif @include('pages.form', ['model' => $page]) - @include('pages.form-toolbox') + @include('pages.editor-toolbox') </form> </div> diff --git a/resources/views/pages/editor-toolbox.blade.php b/resources/views/pages/editor-toolbox.blade.php new file mode 100644 index 000000000..bbf6edee7 --- /dev/null +++ b/resources/views/pages/editor-toolbox.blade.php @@ -0,0 +1,33 @@ +<div editor-toolbox class="floating-toolbox"> + + <div class="tabs primary-background-light"> + <span toolbox-toggle>@icon('caret-left-circle')</span> + <span toolbox-tab-button="tags" title="{{ trans('entities.page_tags') }}" class="active">@icon('tag')</span> + @if(userCan('attachment-create-all')) + <span toolbox-tab-button="files" title="{{ trans('entities.attachments') }}">@icon('attach')</span> + @endif + <span toolbox-tab-button="templates" title="{{ trans('entities.templates') }}">@icon('template')</span> + </div> + + <div toolbox-tab-content="tags"> + <h4>{{ trans('entities.page_tags') }}</h4> + <div class="px-l"> + @include('components.tag-manager', ['entity' => $page, 'entityType' => 'page']) + </div> + </div> + + @if(userCan('attachment-create-all')) + @include('pages.attachment-manager', ['page' => $page]) + @endif + + <div toolbox-tab-content="templates"> + <h4>{{ trans('entities.templates') }}</h4> + + <div class="px-l"> + @include('pages.templates-manager', ['page' => $page]) + </div> + + + </div> + +</div> diff --git a/resources/views/pages/form-toolbox.blade.php b/resources/views/pages/form-toolbox.blade.php deleted file mode 100644 index d69be20c1..000000000 --- a/resources/views/pages/form-toolbox.blade.php +++ /dev/null @@ -1,121 +0,0 @@ - -<div editor-toolbox class="floating-toolbox"> - - <div class="tabs primary-background-light"> - <span toolbox-toggle>@icon('caret-left-circle')</span> - <span toolbox-tab-button="tags" title="{{ trans('entities.page_tags') }}" class="active">@icon('tag')</span> - @if(userCan('attachment-create-all')) - <span toolbox-tab-button="files" title="{{ trans('entities.attachments') }}">@icon('attach')</span> - @endif - </div> - - <div toolbox-tab-content="tags"> - <h4>{{ trans('entities.page_tags') }}</h4> - <div class="px-l"> - @include('components.tag-manager', ['entity' => $page, 'entityType' => 'page']) - </div> - </div> - - @if(userCan('attachment-create-all')) - <div toolbox-tab-content="files" id="attachment-manager" page-id="{{ $page->id ?? 0 }}"> - - @exposeTranslations([ - 'entities.attachments_file_uploaded', - 'entities.attachments_file_updated', - 'entities.attachments_link_attached', - 'entities.attachments_updated_success', - 'errors.server_upload_limit', - 'components.image_upload_remove', - 'components.file_upload_timeout', - ]) - - <h4>{{ trans('entities.attachments') }}</h4> - <div class="px-l files"> - - <div id="file-list" v-show="!fileToEdit"> - <p class="text-muted small">{{ trans('entities.attachments_explain') }} <span class="text-warn">{{ trans('entities.attachments_explain_instant_save') }}</span></p> - - <div class="tab-container"> - <div class="nav-tabs"> - <div @click="tab = 'list'" :class="{selected: tab === 'list'}" class="tab-item">{{ trans('entities.attachments_items') }}</div> - <div @click="tab = 'file'" :class="{selected: tab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div> - <div @click="tab = 'link'" :class="{selected: tab === 'link'}" class="tab-item">{{ trans('entities.attachments_link') }}</div> - </div> - <div v-show="tab === 'list'"> - <draggable style="width: 100%;" :options="{handle: '.handle'}" @change="fileSortUpdate" :list="files" element="div"> - <div v-for="(file, index) in files" :key="file.id" class="card drag-card"> - <div class="handle">@icon('grip')</div> - <div class="py-s"> - <a :href="getFileUrl(file)" target="_blank" v-text="file.name"></a> - <div v-if="file.deleting"> - <span class="text-neg small">{{ trans('entities.attachments_delete_confirm') }}</span> - <br> - <span class="text-primary small" @click="file.deleting = false;">{{ trans('common.cancel') }}</span> - </div> - </div> - <div @click="startEdit(file)" class="drag-card-action text-center text-primary">@icon('edit')</div> - <div @click="deleteFile(file)" class="drag-card-action text-center text-neg">@icon('close')</div> - </div> - </draggable> - <p class="small text-muted" v-if="files.length === 0"> - {{ trans('entities.attachments_no_files') }} - </p> - </div> - <div v-show="tab === 'file'"> - <dropzone placeholder="{{ trans('entities.attachments_dropzone') }}" :upload-url="getUploadUrl()" :uploaded-to="pageId" @success="uploadSuccess"></dropzone> - </div> - <div v-show="tab === 'link'" @keypress.enter.prevent="attachNewLink(file)"> - <p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p> - <div class="form-group"> - <label for="attachment-via-link">{{ trans('entities.attachments_link_name') }}</label> - <input type="text" placeholder="{{ trans('entities.attachments_link_name') }}" v-model="file.name"> - <p class="small text-neg" v-for="error in errors.link.name" v-text="error"></p> - </div> - <div class="form-group"> - <label for="attachment-via-link">{{ trans('entities.attachments_link_url') }}</label> - <input type="text" placeholder="{{ trans('entities.attachments_link_url_hint') }}" v-model="file.link"> - <p class="small text-neg" v-for="error in errors.link.link" v-text="error"></p> - </div> - <button @click.prevent="attachNewLink(file)" class="button primary">{{ trans('entities.attach') }}</button> - - </div> - </div> - - </div> - - <div id="file-edit" v-if="fileToEdit" @keypress.enter.prevent="updateFile(fileToEdit)"> - <h5>{{ trans('entities.attachments_edit_file') }}</h5> - - <div class="form-group"> - <label for="attachment-name-edit">{{ trans('entities.attachments_edit_file_name') }}</label> - <input type="text" id="attachment-name-edit" placeholder="{{ trans('entities.attachments_edit_file_name') }}" v-model="fileToEdit.name"> - <p class="small text-neg" v-for="error in errors.edit.name" v-text="error"></p> - </div> - - <div class="tab-container"> - <div class="nav-tabs"> - <div @click="editTab = 'file'" :class="{selected: editTab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div> - <div @click="editTab = 'link'" :class="{selected: editTab === 'link'}" class="tab-item">{{ trans('entities.attachments_set_link') }}</div> - </div> - <div v-if="editTab === 'file'"> - <dropzone :upload-url="getUploadUrl(fileToEdit)" :uploaded-to="pageId" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" @success="uploadSuccessUpdate"></dropzone> - <br> - </div> - <div v-if="editTab === 'link'"> - <div class="form-group"> - <label for="attachment-link-edit">{{ trans('entities.attachments_link_url') }}</label> - <input type="text" id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" v-model="fileToEdit.link"> - <p class="small text-neg" v-for="error in errors.edit.link" v-text="error"></p> - </div> - </div> - </div> - - <button type="button" class="button outline" @click="cancelEdit">{{ trans('common.back') }}</button> - <button @click.enter.prevent="updateFile(fileToEdit)" class="button primary">{{ trans('common.save') }}</button> - </div> - - </div> - </div> - @endif - -</div> diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index fb0df2ddd..86b0d3f88 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -103,6 +103,12 @@ @endif </div> @endif + + @if($page->template) + <div> + @icon('template'){{ trans('entities.pages_is_template') }} + </div> + @endif </div> </div> diff --git a/resources/views/pages/templates-manager.blade.php b/resources/views/pages/templates-manager.blade.php new file mode 100644 index 000000000..cabb8a43f --- /dev/null +++ b/resources/views/pages/templates-manager.blade.php @@ -0,0 +1,11 @@ +@if(userCan('templates-manage')) + <p class="text-muted small mb-none"> + {{ trans('entities.templates_explain_set_as_template') }} + </p> + @include('components.toggle-switch', [ + 'name' => 'template', + 'value' => old('template', $page->template ? 'true' : 'false') === 'true', + 'label' => trans('entities.templates_set_as_template') + ]) + <hr> +@endif \ No newline at end of file diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php index 6d7230867..0b21a2184 100644 --- a/resources/views/settings/roles/form.blade.php +++ b/resources/views/settings/roles/form.blade.php @@ -38,6 +38,7 @@ <div>@include('settings.roles.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div> <div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div> <div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div> + <div>@include('settings.roles.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div> <div>@include('settings.roles.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div> </div> </div> diff --git a/tests/Entity/PageTemplateTest.php b/tests/Entity/PageTemplateTest.php new file mode 100644 index 000000000..17450f494 --- /dev/null +++ b/tests/Entity/PageTemplateTest.php @@ -0,0 +1,50 @@ +<?php namespace Entity; + +use BookStack\Entities\Page; +use Tests\TestCase; + +class PageTemplateTest extends TestCase +{ + public function test_active_templates_visible_on_page_view() + { + $page = Page::first(); + + $this->asEditor(); + $templateView = $this->get($page->getUrl()); + $templateView->assertDontSee('Page Template'); + + $page->template = true; + $page->save(); + + $templateView = $this->get($page->getUrl()); + $templateView->assertSee('Page Template'); + } + + public function test_manage_templates_permission_required_to_change_page_template_status() + { + $page = Page::first(); + $editor = $this->getEditor(); + $this->actingAs($editor); + + $pageUpdateData = [ + 'name' => $page->name, + 'html' => $page->html, + 'template' => 'true', + ]; + + $this->put($page->getUrl(), $pageUpdateData); + $this->assertDatabaseHas('pages', [ + 'id' => $page->id, + 'template' => false, + ]); + + $this->giveUserPermissions($editor, ['templates-manage']); + + $this->put($page->getUrl(), $pageUpdateData); + $this->assertDatabaseHas('pages', [ + 'id' => $page->id, + 'template' => true, + ]); + } + +} \ No newline at end of file