Merge pull request #5457 from BookStackApp/sort_sets

Sort rules
This commit is contained in:
Dan Brown 2025-02-11 15:41:19 +00:00 committed by GitHub
commit a7de251876
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 2020 additions and 550 deletions

View File

@ -71,6 +71,10 @@ class ActivityType
const IMPORT_RUN = 'import_run';
const IMPORT_DELETE = 'import_delete';
const SORT_RULE_CREATE = 'sort_rule_create';
const SORT_RULE_UPDATE = 'sort_rule_update';
const SORT_RULE_DELETE = 'sort_rule_delete';
/**
* Get all the possible values.
*/

View File

@ -5,6 +5,7 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Http\Controller;
use BookStack\Sorting\SortUrl;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@ -65,6 +66,7 @@ class AuditLogController extends Controller
'filters' => $filters,
'listOptions' => $listOptions,
'activityTypes' => $types,
'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page')))
]);
}
}

View File

@ -96,35 +96,3 @@ function theme_path(string $path = ''): ?string
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
}
/**
* Generate a URL with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction
* Discards empty parameters and allows overriding.
*/
function sortUrl(string $path, array $data, array $overrideData = []): string
{
$queryStringSections = [];
$queryData = array_merge($data, $overrideData);
// Change sorting direction is already sorted on current attribute
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
} elseif (isset($overrideData['sort'])) {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal === '') {
continue;
}
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
}
if (count($queryStringSections) === 0) {
return url($path);
}
return url($path . '?' . implode('&', $queryStringSections));
}

View File

@ -0,0 +1,99 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Book;
use BookStack\Sorting\BookSorter;
use BookStack\Sorting\SortRule;
use Illuminate\Console\Command;
class AssignSortRuleCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:assign-sort-rule
{sort-rule=0: ID of the sort rule to apply}
{--all-books : Apply to all books in the system}
{--books-without-sort : Apply to only books without a sort rule already assigned}
{--books-with-sort= : Apply to only books with the sort rule of given id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Assign a sort rule to content in the system';
/**
* Execute the console command.
*/
public function handle(BookSorter $sorter): int
{
$sortRuleId = intval($this->argument('sort-rule')) ?? 0;
if ($sortRuleId === 0) {
return $this->listSortRules();
}
$rule = SortRule::query()->find($sortRuleId);
if ($this->option('all-books')) {
$query = Book::query();
} else if ($this->option('books-without-sort')) {
$query = Book::query()->whereNull('sort_rule_id');
} else if ($this->option('books-with-sort')) {
$sortId = intval($this->option('books-with-sort')) ?: 0;
if (!$sortId) {
$this->error("Provided --books-with-sort option value is invalid");
return 1;
}
$query = Book::query()->where('sort_rule_id', $sortId);
} else {
$this->error("No option provided to specify target. Run with the -h option to see all available options.");
return 1;
}
if (!$rule) {
$this->error("Sort rule of provided id {$sortRuleId} not found!");
return 1;
}
$count = $query->clone()->count();
$this->warn("This will apply sort rule [{$rule->id}: {$rule->name}] to {$count} book(s) and run the sort on each.");
$confirmed = $this->confirm("Are you sure you want to continue?");
if (!$confirmed) {
return 1;
}
$processed = 0;
$query->chunkById(10, function ($books) use ($rule, $sorter, $count, &$processed) {
$max = min($count, ($processed + 10));
$this->info("Applying to {$processed}-{$max} of {$count} books");
foreach ($books as $book) {
$book->sort_rule_id = $rule->id;
$book->save();
$sorter->runBookAutoSort($book);
}
$processed = $max;
});
$this->info("Sort applied to {$processed} book(s)!");
return 0;
}
protected function listSortRules(): int
{
$rules = SortRule::query()->orderBy('id', 'asc')->get();
$this->error("Sort rule ID required!");
$this->warn("\nAvailable sort rules:");
foreach ($rules as $rule) {
$this->info("{$rule->id}: {$rule->name}");
}
return 1;
}
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Entities\Models;
use BookStack\Sorting\SortRule;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -16,12 +17,14 @@ use Illuminate\Support\Collection;
* @property string $description
* @property int $image_id
* @property ?int $default_template_id
* @property ?int $sort_rule_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
* @property ?SortRule $sortRule
*/
class Book extends Entity implements HasCoverImage
{
@ -82,6 +85,14 @@ class Book extends Entity implements HasCoverImage
return $this->belongsTo(Page::class, 'default_template_id');
}
/**
* Get the sort set assigned to this book, if existing.
*/
public function sortRule(): BelongsTo
{
return $this->belongsTo(SortRule::class);
}
/**
* Get all pages within this book.
*/

View File

@ -4,6 +4,7 @@ namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
@ -12,6 +13,7 @@ use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use BookStack\Sorting\BookSorter;
use BookStack\Uploads\ImageRepo;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Http\UploadedFile;
@ -24,6 +26,7 @@ class BaseRepo
protected ReferenceUpdater $referenceUpdater,
protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries,
protected BookSorter $bookSorter,
) {
}
@ -134,6 +137,18 @@ class BaseRepo
$entity->save();
}
/**
* Sort the parent of the given entity, if any auto sort actions are set for it.
* Typical ran during create/update/insert events.
*/
public function sortParent(Entity $entity): void
{
if ($entity instanceof BookChild) {
$book = $entity->book;
$this->bookSorter->runBookAutoSort($book);
}
}
protected function updateDescription(Entity $entity, array $input): void
{
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {

View File

@ -8,6 +8,7 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Activity;
use BookStack\Sorting\SortRule;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\UploadedFile;
@ -33,6 +34,12 @@ class BookRepo
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::BOOK_CREATE, $book);
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
$book->sort_rule_id = $defaultBookSortSetting;
$book->save();
}
return $book;
}

View File

@ -34,6 +34,8 @@ class ChapterRepo
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
$this->baseRepo->sortParent($chapter);
return $chapter;
}
@ -50,6 +52,8 @@ class ChapterRepo
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
$this->baseRepo->sortParent($chapter);
return $chapter;
}
@ -88,6 +92,8 @@ class ChapterRepo
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
$this->baseRepo->sortParent($chapter);
return $parent;
}
}

View File

@ -83,6 +83,7 @@ class PageRepo
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
$this->baseRepo->sortParent($draft);
return $draft;
}
@ -128,6 +129,7 @@ class PageRepo
}
Activity::add(ActivityType::PAGE_UPDATE, $page);
$this->baseRepo->sortParent($page);
return $page;
}
@ -243,6 +245,8 @@ class PageRepo
Activity::add(ActivityType::PAGE_RESTORE, $page);
Activity::add(ActivityType::REVISION_RESTORE, $revision);
$this->baseRepo->sortParent($page);
return $page;
}
@ -272,6 +276,8 @@ class PageRepo
Activity::add(ActivityType::PAGE_MOVE, $page);
$this->baseRepo->sortParent($page);
return $parent;
}

View File

@ -8,6 +8,8 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Sorting\BookSortMap;
use BookStack\Sorting\BookSortMapItem;
use Illuminate\Support\Collection;
class BookContents
@ -103,211 +105,4 @@ class BookContents
return $query->where('book_id', '=', $this->book->id)->get();
}
/**
* Sort the books content using the given sort map.
* Returns a list of books that were involved in the operation.
*
* @returns Book[]
*/
public function sortUsingMap(BookSortMap $sortMap): array
{
// Load models into map
$modelMap = $this->loadModelsFromSortMap($sortMap);
// Sort our changes from our map to be chapters first
// Since they need to be process to ensure book alignment for child page changes.
$sortMapItems = $sortMap->all();
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
$aScore = $itemA->type === 'page' ? 2 : 1;
$bScore = $itemB->type === 'page' ? 2 : 1;
return $aScore - $bScore;
});
// Perform the sort
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return str_starts_with($key, 'book:');
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
foreach ($booksInvolved as $book) {
$book->rebuildPermissions();
}
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required. Changes where permissions are lacking will
* be skipped and not throw an error.
*
* @param array<string, Entity> $modelMap
*/
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
return;
}
$priorityChanged = $model->priority !== $sortMapItem->sort;
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
// Stop if there's no change
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
return;
}
$currentParentKey = 'book:' . $model->book_id;
if ($model instanceof Page && $model->chapter_id) {
$currentParentKey = 'chapter:' . $model->chapter_id;
}
$currentParent = $modelMap[$currentParentKey] ?? null;
/** @var Book $newBook */
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
/** @var ?Chapter $newChapter */
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
return;
}
// Action the required changes
if ($bookChanged) {
$model->changeBook($newBook->id);
}
if ($model instanceof Page && $chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0;
}
if ($priorityChanged) {
$model->priority = $sortMapItem->sort;
}
if ($chapterChanged || $priorityChanged) {
$model->save();
}
}
/**
* Check if the current user has permissions to apply the given sorting change.
* Is quite complex since items can gain a different parent change. Acts as a:
* - Update of old parent element (Change of content/order).
* - Update of sorted/moved element.
* - Deletion of element (Relative to parent upon move).
* - Creation of element within parent (Upon move to new parent).
*/
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
{
// Stop if we can't see the current parent or new book.
if (!$currentParent || !$newBook) {
return false;
}
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
if ($model instanceof Chapter) {
$hasPermission = userCan('book-update', $currentParent)
&& userCan('book-update', $newBook)
&& userCan('chapter-update', $model)
&& (!$hasNewParent || userCan('chapter-create', $newBook))
&& (!$hasNewParent || userCan('chapter-delete', $model));
if (!$hasPermission) {
return false;
}
}
if ($model instanceof Page) {
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
// This needs to check if there was an intended chapter location in the original sort map
// rather than inferring from the $newChapter since that variable may be null
// due to other reasons (Visibility).
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
if (!$newParent) {
return false;
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
$hasPermission = $hasCurrentParentPermission
&& $newParentInRightLocation
&& $hasNewParentPermission
&& $hasPageEditPermission
&& $hasDeletePermissionIfMoving
&& $hasCreatePermissionIfMoving;
if (!$hasPermission) {
return false;
}
}
return true;
}
/**
* Load models from the database into the given sort map.
*
* @return array<string, Entity>
*/
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
{
$modelMap = [];
$ids = [
'chapter' => [],
'page' => [],
'book' => [],
];
foreach ($sortMap->all() as $sortMapItem) {
$ids[$sortMapItem->type][] = $sortMapItem->id;
$ids['book'][] = $sortMapItem->parentBookId;
if ($sortMapItem->parentChapterId) {
$ids['chapter'][] = $sortMapItem->parentChapterId;
}
}
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
$ids['book'][] = $page->book_id;
if ($page->chapter_id) {
$ids['chapter'][] = $page->chapter_id;
}
}
$chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}
return $modelMap;
}
}

View File

@ -1,11 +1,10 @@
<?php
namespace BookStack\Entities\Controllers;
namespace BookStack\Sorting;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\BookSortMap;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
@ -45,25 +44,40 @@ class BookSortController extends Controller
}
/**
* Sorts a book using a given mapping array.
* Update the sort options of a book, setting the auto-sort and/or updating
* child order via mapping.
*/
public function update(Request $request, string $bookSlug)
public function update(Request $request, BookSorter $sorter, string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$loggedActivityForBook = false;
// Return if no map sent
if (!$request->filled('sort-tree')) {
return redirect($book->getUrl());
// Sort via map
if ($request->filled('sort-tree')) {
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$booksInvolved = $sorter->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
if ($bookInvolved->id === $book->id) {
$loggedActivityForBook = true;
}
}
}
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$bookContents = new BookContents($book);
$booksInvolved = $bookContents->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
if ($request->filled('auto-sort')) {
$sortSetId = intval($request->get('auto-sort')) ?: null;
if ($sortSetId && SortRule::query()->find($sortSetId) === null) {
$sortSetId = null;
}
$book->sort_rule_id = $sortSetId;
$book->save();
$sorter->runBookAutoSort($book);
if (!$loggedActivityForBook) {
Activity::add(ActivityType::BOOK_SORT, $book);
}
}
return redirect($book->getUrl());

View File

@ -1,6 +1,6 @@
<?php
namespace BookStack\Entities\Tools;
namespace BookStack\Sorting;
class BookSortMap
{

View File

@ -1,6 +1,6 @@
<?php
namespace BookStack\Entities\Tools;
namespace BookStack\Sorting;
class BookSortMapItem
{

284
app/Sorting/BookSorter.php Normal file
View File

@ -0,0 +1,284 @@
<?php
namespace BookStack\Sorting;
use BookStack\App\Model;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
class BookSorter
{
public function __construct(
protected EntityQueries $queries,
) {
}
public function runBookAutoSortForAllWithSet(SortRule $set): void
{
$set->books()->chunk(50, function ($books) {
foreach ($books as $book) {
$this->runBookAutoSort($book);
}
});
}
/**
* Runs the auto-sort for a book if the book has a sort set applied to it.
* This does not consider permissions since the sort operations are centrally
* managed by admins so considered permitted if existing and assigned.
*/
public function runBookAutoSort(Book $book): void
{
$set = $book->sortRule;
if (!$set) {
return;
}
$sortFunctions = array_map(function (SortRuleOperation $op) {
return $op->getSortFunction();
}, $set->getOperations());
$chapters = $book->chapters()
->with('pages:id,name,priority,created_at,updated_at,chapter_id')
->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
/** @var (Chapter|Book)[] $topItems */
$topItems = [
...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
...$chapters,
];
foreach ($sortFunctions as $sortFunction) {
usort($topItems, $sortFunction);
}
foreach ($topItems as $index => $topItem) {
$topItem->priority = $index + 1;
$topItem::withoutTimestamps(fn () => $topItem->save());
}
foreach ($chapters as $chapter) {
$pages = $chapter->pages->all();
foreach ($sortFunctions as $sortFunction) {
usort($pages, $sortFunction);
}
foreach ($pages as $index => $page) {
$page->priority = $index + 1;
$page::withoutTimestamps(fn () => $page->save());
}
}
}
/**
* Sort the books content using the given sort map.
* Returns a list of books that were involved in the operation.
*
* @returns Book[]
*/
public function sortUsingMap(BookSortMap $sortMap): array
{
// Load models into map
$modelMap = $this->loadModelsFromSortMap($sortMap);
// Sort our changes from our map to be chapters first
// Since they need to be process to ensure book alignment for child page changes.
$sortMapItems = $sortMap->all();
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
$aScore = $itemA->type === 'page' ? 2 : 1;
$bScore = $itemB->type === 'page' ? 2 : 1;
return $aScore - $bScore;
});
// Perform the sort
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return str_starts_with($key, 'book:');
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
foreach ($booksInvolved as $book) {
$book->rebuildPermissions();
}
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required. Changes where permissions are lacking will
* be skipped and not throw an error.
*
* @param array<string, Entity> $modelMap
*/
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
return;
}
$priorityChanged = $model->priority !== $sortMapItem->sort;
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
// Stop if there's no change
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
return;
}
$currentParentKey = 'book:' . $model->book_id;
if ($model instanceof Page && $model->chapter_id) {
$currentParentKey = 'chapter:' . $model->chapter_id;
}
$currentParent = $modelMap[$currentParentKey] ?? null;
/** @var Book $newBook */
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
/** @var ?Chapter $newChapter */
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
return;
}
// Action the required changes
if ($bookChanged) {
$model->changeBook($newBook->id);
}
if ($model instanceof Page && $chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0;
}
if ($priorityChanged) {
$model->priority = $sortMapItem->sort;
}
if ($chapterChanged || $priorityChanged) {
$model::withoutTimestamps(fn () => $model->save());
}
}
/**
* Check if the current user has permissions to apply the given sorting change.
* Is quite complex since items can gain a different parent change. Acts as a:
* - Update of old parent element (Change of content/order).
* - Update of sorted/moved element.
* - Deletion of element (Relative to parent upon move).
* - Creation of element within parent (Upon move to new parent).
*/
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
{
// Stop if we can't see the current parent or new book.
if (!$currentParent || !$newBook) {
return false;
}
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
if ($model instanceof Chapter) {
$hasPermission = userCan('book-update', $currentParent)
&& userCan('book-update', $newBook)
&& userCan('chapter-update', $model)
&& (!$hasNewParent || userCan('chapter-create', $newBook))
&& (!$hasNewParent || userCan('chapter-delete', $model));
if (!$hasPermission) {
return false;
}
}
if ($model instanceof Page) {
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
// This needs to check if there was an intended chapter location in the original sort map
// rather than inferring from the $newChapter since that variable may be null
// due to other reasons (Visibility).
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
if (!$newParent) {
return false;
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
$hasPermission = $hasCurrentParentPermission
&& $newParentInRightLocation
&& $hasNewParentPermission
&& $hasPageEditPermission
&& $hasDeletePermissionIfMoving
&& $hasCreatePermissionIfMoving;
if (!$hasPermission) {
return false;
}
}
return true;
}
/**
* Load models from the database into the given sort map.
*
* @return array<string, Entity>
*/
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
{
$modelMap = [];
$ids = [
'chapter' => [],
'page' => [],
'book' => [],
];
foreach ($sortMap->all() as $sortMapItem) {
$ids[$sortMapItem->type][] = $sortMapItem->id;
$ids['book'][] = $sortMapItem->parentBookId;
if ($sortMapItem->parentChapterId) {
$ids['chapter'][] = $sortMapItem->parentChapterId;
}
}
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
$ids['book'][] = $page->book_id;
if ($page->chapter_id) {
$ids['chapter'][] = $page->chapter_id;
}
}
$chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}
return $modelMap;
}
}

63
app/Sorting/SortRule.php Normal file
View File

@ -0,0 +1,63 @@
<?php
namespace BookStack\Sorting;
use BookStack\Activity\Models\Loggable;
use BookStack\Entities\Models\Book;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property string $sequence
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class SortRule extends Model implements Loggable
{
use HasFactory;
/**
* @return SortRuleOperation[]
*/
public function getOperations(): array
{
return SortRuleOperation::fromSequence($this->sequence);
}
/**
* @param SortRuleOperation[] $options
*/
public function setOperations(array $options): void
{
$values = array_map(fn (SortRuleOperation $opt) => $opt->value, $options);
$this->sequence = implode(',', $values);
}
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
public function getUrl(): string
{
return url("/settings/sorting/rules/{$this->id}");
}
public function books(): HasMany
{
return $this->hasMany(Book::class);
}
public static function allByName(): Collection
{
return static::query()
->withCount('books')
->orderBy('name', 'asc')
->get();
}
}

View File

@ -0,0 +1,114 @@
<?php
namespace BookStack\Sorting;
use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class SortRuleController extends Controller
{
public function __construct()
{
$this->middleware('can:settings-manage');
}
public function create()
{
$this->setPageTitle(trans('settings.sort_rule_create'));
return view('settings.sort-rules.create');
}
public function store(Request $request)
{
$this->validate($request, [
'name' => ['required', 'string', 'min:1', 'max:200'],
'sequence' => ['required', 'string', 'min:1'],
]);
$operations = SortRuleOperation::fromSequence($request->input('sequence'));
if (count($operations) === 0) {
return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']);
}
$rule = new SortRule();
$rule->name = $request->input('name');
$rule->setOperations($operations);
$rule->save();
$this->logActivity(ActivityType::SORT_RULE_CREATE, $rule);
return redirect('/settings/sorting');
}
public function edit(string $id)
{
$rule = SortRule::query()->findOrFail($id);
$this->setPageTitle(trans('settings.sort_rule_edit'));
return view('settings.sort-rules.edit', ['rule' => $rule]);
}
public function update(string $id, Request $request, BookSorter $bookSorter)
{
$this->validate($request, [
'name' => ['required', 'string', 'min:1', 'max:200'],
'sequence' => ['required', 'string', 'min:1'],
]);
$rule = SortRule::query()->findOrFail($id);
$operations = SortRuleOperation::fromSequence($request->input('sequence'));
if (count($operations) === 0) {
return redirect($rule->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']);
}
$rule->name = $request->input('name');
$rule->setOperations($operations);
$changedSequence = $rule->isDirty('sequence');
$rule->save();
$this->logActivity(ActivityType::SORT_RULE_UPDATE, $rule);
if ($changedSequence) {
$bookSorter->runBookAutoSortForAllWithSet($rule);
}
return redirect('/settings/sorting');
}
public function destroy(string $id, Request $request)
{
$rule = SortRule::query()->findOrFail($id);
$confirmed = $request->input('confirm') === 'true';
$booksAssigned = $rule->books()->count();
$warnings = [];
if ($booksAssigned > 0) {
if ($confirmed) {
$rule->books()->update(['sort_rule_id' => null]);
} else {
$warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
}
}
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
if ($defaultBookSortSetting === intval($id)) {
if ($confirmed) {
setting()->remove('sorting-book-default');
} else {
$warnings[] = trans('settings.sort_rule_delete_warn_default');
}
}
if (count($warnings) > 0) {
return redirect($rule->getUrl() . '#delete')->withErrors(['delete' => $warnings]);
}
$rule->delete();
$this->logActivity(ActivityType::SORT_RULE_DELETE, $rule);
return redirect('/settings/sorting');
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace BookStack\Sorting;
use Closure;
use Illuminate\Support\Str;
enum SortRuleOperation: string
{
case NameAsc = 'name_asc';
case NameDesc = 'name_desc';
case NameNumericAsc = 'name_numeric_asc';
case CreatedDateAsc = 'created_date_asc';
case CreatedDateDesc = 'created_date_desc';
case UpdateDateAsc = 'updated_date_asc';
case UpdateDateDesc = 'updated_date_desc';
case ChaptersFirst = 'chapters_first';
case ChaptersLast = 'chapters_last';
/**
* Provide a translated label string for this option.
*/
public function getLabel(): string
{
$key = $this->value;
$label = '';
if (str_ends_with($key, '_asc')) {
$key = substr($key, 0, -4);
$label = trans('settings.sort_rule_op_asc');
} elseif (str_ends_with($key, '_desc')) {
$key = substr($key, 0, -5);
$label = trans('settings.sort_rule_op_desc');
}
$label = trans('settings.sort_rule_op_' . $key) . ' ' . $label;
return trim($label);
}
public function getSortFunction(): callable
{
$camelValue = Str::camel($this->value);
return SortSetOperationComparisons::$camelValue(...);
}
/**
* @return SortRuleOperation[]
*/
public static function allExcluding(array $operations): array
{
$all = SortRuleOperation::cases();
$filtered = array_filter($all, function (SortRuleOperation $operation) use ($operations) {
return !in_array($operation, $operations);
});
return array_values($filtered);
}
/**
* Create a set of operations from a string sequence representation.
* (values seperated by commas).
* @return SortRuleOperation[]
*/
public static function fromSequence(string $sequence): array
{
$strOptions = explode(',', $sequence);
$options = array_map(fn ($val) => SortRuleOperation::tryFrom($val), $strOptions);
return array_filter($options);
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace BookStack\Sorting;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
/**
* Sort comparison function for each of the possible SortSetOperation values.
* Method names should be camelCase names for the SortSetOperation enum value.
* TODO - Test to cover each SortSetOperation enum value is covered.
*/
class SortSetOperationComparisons
{
public static function nameAsc(Entity $a, Entity $b): int
{
return $a->name <=> $b->name;
}
public static function nameDesc(Entity $a, Entity $b): int
{
return $b->name <=> $a->name;
}
public static function nameNumericAsc(Entity $a, Entity $b): int
{
$numRegex = '/^\d+(\.\d+)?/';
$aMatches = [];
$bMatches = [];
preg_match($numRegex, $a, $aMatches);
preg_match($numRegex, $b, $bMatches);
return ($aMatches[0] ?? 0) <=> ($bMatches[0] ?? 0);
}
public static function nameNumericDesc(Entity $a, Entity $b): int
{
return -(static::nameNumericAsc($a, $b));
}
public static function createdDateAsc(Entity $a, Entity $b): int
{
return $a->created_at->unix() <=> $b->created_at->unix();
}
public static function createdDateDesc(Entity $a, Entity $b): int
{
return $b->created_at->unix() <=> $a->created_at->unix();
}
public static function updatedDateAsc(Entity $a, Entity $b): int
{
return $a->updated_at->unix() <=> $b->updated_at->unix();
}
public static function updatedDateDesc(Entity $a, Entity $b): int
{
return $b->updated_at->unix() <=> $a->updated_at->unix();
}
public static function chaptersFirst(Entity $a, Entity $b): int
{
return ($b instanceof Chapter ? 1 : 0) - (($a instanceof Chapter) ? 1 : 0);
}
public static function chaptersLast(Entity $a, Entity $b): int
{
return ($a instanceof Chapter ? 1 : 0) - (($b instanceof Chapter) ? 1 : 0);
}
}

49
app/Sorting/SortUrl.php Normal file
View File

@ -0,0 +1,49 @@
<?php
namespace BookStack\Sorting;
/**
* Generate a URL with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction
* Discards empty parameters and allows overriding.
*/
class SortUrl
{
public function __construct(
protected string $path,
protected array $data,
protected array $overrideData = []
) {
}
public function withOverrideData(array $overrideData = []): self
{
return new self($this->path, $this->data, $overrideData);
}
public function build(): string
{
$queryStringSections = [];
$queryData = array_merge($this->data, $this->overrideData);
// Change sorting direction if already sorted on current attribute
if (isset($this->overrideData['sort']) && $this->overrideData['sort'] === $this->data['sort']) {
$queryData['order'] = ($this->data['order'] === 'asc') ? 'desc' : 'asc';
} elseif (isset($this->overrideData['sort'])) {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal !== '') {
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
}
}
if (count($queryStringSections) === 0) {
return url($this->path);
}
return url($this->path . '?' . implode('&', $queryStringSections));
}
}

View File

@ -26,7 +26,9 @@ class BookFactory extends Factory
'name' => $this->faker->sentence(),
'slug' => Str::random(10),
'description' => $description,
'description_html' => '<p>' . e($description) . '</p>'
'description_html' => '<p>' . e($description) . '</p>',
'sort_rule_id' => null,
'default_template_id' => null,
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories\Sorting;
use BookStack\Sorting\SortRule;
use BookStack\Sorting\SortRuleOperation;
use Illuminate\Database\Eloquent\Factories\Factory;
class SortRuleFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = SortRule::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
$cases = SortRuleOperation::cases();
$op = $cases[array_rand($cases)];
return [
'name' => $op->name . ' Sort',
'sequence' => $op->value,
];
}
}

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sort_rules', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->text('sequence');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sort_rules');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('books', function (Blueprint $table) {
$table->unsignedInteger('sort_rule_id')->nullable()->default(null);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('books', function (Blueprint $table) {
$table->dropColumn('sort_rule_id');
});
}
};

View File

@ -127,6 +127,14 @@ return [
'comment_update' => 'updated comment',
'comment_delete' => 'deleted comment',
// Sort Rules
'sort_rule_create' => 'created sort rule',
'sort_rule_create_notification' => 'Sort rule successfully created',
'sort_rule_update' => 'updated sort rule',
'sort_rule_update_notification' => 'Sort rule successfully update',
'sort_rule_delete' => 'deleted sort rule',
'sort_rule_delete_notification' => 'Sort rule successfully deleted',
// Other
'permissions_update' => 'updated permissions',
];

View File

@ -166,7 +166,9 @@ return [
'books_search_this' => 'Search this book',
'books_navigation' => 'Book Navigation',
'books_sort' => 'Sort Book Contents',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.',
'books_sort_auto_sort' => 'Auto Sort Option',
'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName',
'books_sort_named' => 'Sort Book :bookName',
'books_sort_name' => 'Sort by Name',
'books_sort_created' => 'Sort by Created Date',

View File

@ -74,6 +74,36 @@ return [
'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application. <br> Note that users will be able to change their email addresses after successful registration.',
'reg_confirm_restrict_domain_placeholder' => 'No restriction set',
// Sorting Settings
'sorting' => 'Sorting',
'sorting_book_default' => 'Default Book Sort',
'sorting_book_default_desc' => 'Select the default sort role to apply to new books. This won\'t affect existing books, and can be overridden per-book.',
'sorting_rules' => 'Sort Rules',
'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.',
'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books',
'sort_rule_create' => 'Create Sort Rule',
'sort_rule_edit' => 'Edit Sort Rule',
'sort_rule_delete' => 'Delete Sort Rule',
'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.',
'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?',
'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?',
'sort_rule_details' => 'Sort Rule Details',
'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.',
'sort_rule_operations' => 'Sort Operations',
'sort_rule_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.',
'sort_rule_available_operations' => 'Available Operations',
'sort_rule_available_operations_empty' => 'No operations remaining',
'sort_rule_configured_operations' => 'Configured Operations',
'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list',
'sort_rule_op_asc' => '(Asc)',
'sort_rule_op_desc' => '(Desc)',
'sort_rule_op_name' => 'Name - Alphabetical',
'sort_rule_op_name_numeric' => 'Name - Numeric',
'sort_rule_op_created_date' => 'Created Date',
'sort_rule_op_updated_date' => 'Updated Date',
'sort_rule_op_chapters_first' => 'Chapters First',
'sort_rule_op_chapters_last' => 'Chapters Last',
// Maintenance settings
'maint' => 'Maintenance',
'maint_image_cleanup' => 'Cleanup Images',

9
package-lock.json generated
View File

@ -4,7 +4,6 @@
"requires": true,
"packages": {
"": {
"name": "bookstack",
"dependencies": {
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-css": "^6.3.1",
@ -32,6 +31,7 @@
},
"devDependencies": {
"@lezer/generator": "^1.7.2",
"@types/sortablejs": "^1.15.8",
"chokidar-cli": "^3.0",
"esbuild": "^0.24.0",
"eslint": "^8.57.1",
@ -2403,6 +2403,13 @@
"undici-types": "~6.19.2"
}
},
"node_modules/@types/sortablejs": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz",
"integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",

View File

@ -20,6 +20,7 @@
},
"devDependencies": {
"@lezer/generator": "^1.7.2",
"@types/sortablejs": "^1.15.8",
"chokidar-cli": "^3.0",
"esbuild": "^0.24.0",
"eslint": "^8.57.1",

View File

@ -0,0 +1 @@
<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m3 18h6v-2h-6zm0-12v2h18v-2zm0 7h11v-2h-11z"/><g transform="matrix(.024132 0 0 .024132 3.6253 26.687)"><path d="m602.72-360v-146.6h-58.639l117.28-205.24v146.6h58.639z" stroke-width=".73298"/></g></svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@ -50,6 +50,7 @@ export {ShelfSort} from './shelf-sort';
export {Shortcuts} from './shortcuts';
export {ShortcutInput} from './shortcut-input';
export {SortableList} from './sortable-list';
export {SortRuleManager} from './sort-rule-manager'
export {SubmitOnChange} from './submit-on-change';
export {Tabs} from './tabs';
export {TagManager} from './tag-manager';

View File

@ -1,29 +1,6 @@
import Sortable from 'sortablejs';
import {Component} from './component';
/**
* @type {Object<string, function(HTMLElement, HTMLElement, HTMLElement)>}
*/
const itemActions = {
move_up(item) {
const list = item.parentNode;
const index = Array.from(list.children).indexOf(item);
const newIndex = Math.max(index - 1, 0);
list.insertBefore(item, list.children[newIndex] || null);
},
move_down(item) {
const list = item.parentNode;
const index = Array.from(list.children).indexOf(item);
const newIndex = Math.min(index + 2, list.children.length);
list.insertBefore(item, list.children[newIndex] || null);
},
remove(item, shelfBooksList, allBooksList) {
allBooksList.appendChild(item);
},
add(item, shelfBooksList) {
shelfBooksList.appendChild(item);
},
};
import {buildListActions, sortActionClickListener} from '../services/dual-lists.ts';
export class ShelfSort extends Component {
@ -55,12 +32,9 @@ export class ShelfSort extends Component {
}
setupListeners() {
this.elem.addEventListener('click', event => {
const sortItemAction = event.target.closest('.scroll-box-item button[data-action]');
if (sortItemAction) {
this.sortItemActionClick(sortItemAction);
}
});
const listActions = buildListActions(this.allBookList, this.shelfBookList);
const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this));
this.elem.addEventListener('click', sortActionListener);
this.bookSearchInput.addEventListener('input', () => {
this.filterBooksByName(this.bookSearchInput.value);
@ -93,20 +67,6 @@ export class ShelfSort extends Component {
}
}
/**
* Called when a sort item action button is clicked.
* @param {HTMLElement} sortItemAction
*/
sortItemActionClick(sortItemAction) {
const sortItem = sortItemAction.closest('.scroll-box-item');
const {action} = sortItemAction.dataset;
const actionFunction = itemActions[action];
actionFunction(sortItem, this.shelfBookList, this.allBookList);
this.onChange();
}
onChange() {
const shelfBookElems = Array.from(this.shelfBookList.querySelectorAll('[data-id]'));
this.input.value = shelfBookElems.map(elem => elem.getAttribute('data-id')).join(',');

View File

@ -0,0 +1,41 @@
import {Component} from "./component.js";
import Sortable from "sortablejs";
import {buildListActions, sortActionClickListener} from "../services/dual-lists";
export class SortRuleManager extends Component {
protected input!: HTMLInputElement;
protected configuredList!: HTMLElement;
protected availableList!: HTMLElement;
setup() {
this.input = this.$refs.input as HTMLInputElement;
this.configuredList = this.$refs.configuredOperationsList;
this.availableList = this.$refs.availableOperationsList;
this.initSortable();
const listActions = buildListActions(this.availableList, this.configuredList);
const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this));
this.$el.addEventListener('click', sortActionListener);
}
initSortable() {
const scrollBoxes = [this.configuredList, this.availableList];
for (const scrollBox of scrollBoxes) {
new Sortable(scrollBox, {
group: 'sort-rule-operations',
ghostClass: 'primary-background-light',
handle: '.handle',
animation: 150,
onSort: this.onChange.bind(this),
});
}
}
onChange() {
const configuredOpEls = Array.from(this.configuredList.querySelectorAll('[data-id]'));
this.input.value = configuredOpEls.map(elem => elem.getAttribute('data-id')).join(',');
}
}

View File

@ -0,0 +1,51 @@
/**
* Service for helping manage common dual-list scenarios.
* (Shelf book manager, sort set manager).
*/
type ListActionsSet = Record<string, ((item: HTMLElement) => void)>;
export function buildListActions(
availableList: HTMLElement,
configuredList: HTMLElement,
): ListActionsSet {
return {
move_up(item) {
const list = item.parentNode as HTMLElement;
const index = Array.from(list.children).indexOf(item);
const newIndex = Math.max(index - 1, 0);
list.insertBefore(item, list.children[newIndex] || null);
},
move_down(item) {
const list = item.parentNode as HTMLElement;
const index = Array.from(list.children).indexOf(item);
const newIndex = Math.min(index + 2, list.children.length);
list.insertBefore(item, list.children[newIndex] || null);
},
remove(item) {
availableList.appendChild(item);
},
add(item) {
configuredList.appendChild(item);
},
};
}
export function sortActionClickListener(actions: ListActionsSet, onChange: () => void) {
return (event: MouseEvent) => {
const sortItemAction = (event.target as Element).closest('.scroll-box-item button[data-action]') as HTMLElement|null;
if (sortItemAction) {
const sortItem = sortItemAction.closest('.scroll-box-item') as HTMLElement;
const action = sortItemAction.dataset.action;
if (!action) {
throw new Error('No action defined for clicked button');
}
const actionFunction = actions[action];
actionFunction(sortItem);
onChange();
}
};
}

View File

@ -1062,12 +1062,16 @@ $btt-size: 40px;
cursor: pointer;
@include mixins.lightDark(background-color, #f8f8f8, #333);
}
&.items-center {
align-items: center;
}
.handle {
color: #AAA;
cursor: grab;
}
button {
opacity: .6;
line-height: 1;
}
.handle svg {
margin: 0;
@ -1108,12 +1112,19 @@ input.scroll-box-search, .scroll-box-header-item {
border-radius: 0 0 3px 3px;
}
.scroll-box[refs="shelf-sort@shelf-book-list"] [data-action="add"] {
.scroll-box.configured-option-list [data-action="add"] {
display: none;
}
.scroll-box[refs="shelf-sort@all-book-list"] [data-action="remove"],
.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_up"],
.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_down"],
.scroll-box.available-option-list [data-action="remove"],
.scroll-box.available-option-list [data-action="move_up"],
.scroll-box.available-option-list [data-action="move_down"],
{
display: none;
}
.scroll-box > li.empty-state {
display: none;
}
.scroll-box > li.empty-state:last-child {
display: list-item;
}

View File

@ -242,6 +242,10 @@
margin-bottom: vars.$m;
padding: vars.$m vars.$xl;
position: relative;
summary:focus {
outline: 1px dashed var(--color-primary);
outline-offset: 5px;
}
&::before {
pointer-events: none;
content: '';

View File

@ -8,14 +8,24 @@
<span>@icon('book')</span>
<span>{{ $book->name }}</span>
</div>
<div class="flex-container-row items-center text-book">
@if($book->sortRule)
<span title="{{ trans('entities.books_sort_auto_sort_active', ['sortName' => $book->sortRule->name]) }}">@icon('auto-sort')</span>
@endif
</div>
</h5>
</summary>
<div class="sort-box-options pb-sm">
<button type="button" data-sort="name" class="button outline small">{{ trans('entities.books_sort_name') }}</button>
<button type="button" data-sort="created" class="button outline small">{{ trans('entities.books_sort_created') }}</button>
<button type="button" data-sort="updated" class="button outline small">{{ trans('entities.books_sort_updated') }}</button>
<button type="button" data-sort="chaptersFirst" class="button outline small">{{ trans('entities.books_sort_chapters_first') }}</button>
<button type="button" data-sort="chaptersLast" class="button outline small">{{ trans('entities.books_sort_chapters_last') }}</button>
<button type="button" data-sort="name"
class="button outline small">{{ trans('entities.books_sort_name') }}</button>
<button type="button" data-sort="created"
class="button outline small">{{ trans('entities.books_sort_created') }}</button>
<button type="button" data-sort="updated"
class="button outline small">{{ trans('entities.books_sort_updated') }}</button>
<button type="button" data-sort="chaptersFirst"
class="button outline small">{{ trans('entities.books_sort_chapters_first') }}</button>
<button type="button" data-sort="chaptersLast"
class="button outline small">{{ trans('entities.books_sort_chapters_last') }}</button>
</div>
<ul class="sortable-page-list sort-list">

View File

@ -18,14 +18,38 @@
<div>
<div component="book-sort" class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('entities.books_sort') }}</h1>
<p class="text-muted">{{ trans('entities.books_sort_desc') }}</p>
<div class="flex-container-row gap-m wrap mb-m">
<p class="text-muted flex min-width-s mb-none">{{ trans('entities.books_sort_desc') }}</p>
<div class="min-width-s">
@php
$autoSortVal = intval(old('auto-sort') ?? $book->sort_rule_id ?? 0);
@endphp
<label for="auto-sort">{{ trans('entities.books_sort_auto_sort') }}</label>
<select id="auto-sort"
name="auto-sort"
form="sort-form"
class="{{ $errors->has('auto-sort') ? 'neg' : '' }}">
<option value="0" @if($autoSortVal === 0) selected @endif>-- {{ trans('common.none') }}
--
</option>
@foreach(\BookStack\Sorting\SortRule::allByName() as $rule)
<option value="{{$rule->id}}"
@if($autoSortVal === $rule->id) selected @endif
>
{{ $rule->name }}
</option>
@endforeach
</select>
</div>
</div>
<div refs="book-sort@sortContainer">
@include('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren])
</div>
<form action="{{ $book->getUrl('/sort') }}" method="POST">
{!! csrf_field() !!}
<form id="sort-form" action="{{ $book->getUrl('/sort') }}" method="POST">
{{ csrf_field() }}
<input type="hidden" name="_method" value="PUT">
<input refs="book-sort@input" type="hidden" name="sort-tree">
<div class="list text-right">

View File

@ -26,11 +26,11 @@
class="input-base text-left">{{ $filters['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
<ul refs="dropdown@menu" class="dropdown-menu">
<li @if($filters['event'] === '') class="active" @endif><a
href="{{ sortUrl('/settings/audit', array_filter(request()->except('page')), ['event' => '']) }}"
href="{{ $filterSortUrl->withOverrideData(['event' => ''])->build() }}"
class="text-item">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
@foreach($activityTypes as $type)
<li @if($type === $filters['event']) class="active" @endif><a
href="{{ sortUrl('/settings/audit', array_filter(request()->except('page')), ['event' => $type]) }}"
href="{{ $filterSortUrl->withOverrideData(['event' => $type])->build() }}"
class="text-item">{{ $type }}</a></li>
@endforeach
</ul>

View File

@ -0,0 +1,68 @@
@extends('settings.layout')
@php
$sortRules = \BookStack\Sorting\SortRule::allByName();
@endphp
@section('card')
<h1 id="sorting" class="list-heading">{{ trans('settings.sorting') }}</h1>
<form action="{{ url("/settings/sorting") }}" method="POST">
{{ csrf_field() }}
<input type="hidden" name="section" value="sorting">
<div class="setting-list">
<div class="grid half gap-xl items-center">
<div>
<label for="setting-sorting-book-default"
class="setting-list-label">{{ trans('settings.sorting_book_default') }}</label>
<p class="small">{{ trans('settings.sorting_book_default_desc') }}</p>
</div>
<div>
<select id="setting-sorting-book-default" name="setting-sorting-book-default"
@if($errors->has('setting-sorting-book-default')) class="neg" @endif>
<option value="0" @if(intval(setting('sorting-book-default', '0')) === 0) selected @endif>
-- {{ trans('common.none') }} --
</option>
@foreach($sortRules as $set)
<option value="{{$set->id}}"
@if(intval(setting('sorting-book-default', '0')) === $set->id) selected @endif
>
{{ $set->name }}
</option>
@endforeach
</select>
</div>
</div>
</div>
<div class="form-group text-right">
<button type="submit" class="button">{{ trans('settings.settings_save') }}</button>
</div>
</form>
@endsection
@section('after-card')
<div class="card content-wrap auto-height">
<div class="flex-container-row items-center gap-m">
<div class="flex">
<h2 class="list-heading">{{ trans('settings.sorting_rules') }}</h2>
<p class="text-muted">{{ trans('settings.sorting_rules_desc') }}</p>
</div>
<div>
<a href="{{ url('/settings/sorting/rules/new') }}"
class="button outline">{{ trans('settings.sort_rule_create') }}</a>
</div>
</div>
@if(empty($sortRules))
<p class="italic text-muted">{{ trans('common.no_items') }}</p>
@else
<div class="item-list">
@foreach($sortRules as $rule)
@include('settings.sort-rules.parts.sort-rule-list-item', ['rule' => $rule])
@endforeach
</div>
@endif
</div>
@endsection

View File

@ -13,6 +13,7 @@
<a href="{{ url('/settings/features') }}" class="{{ $category === 'features' ? 'active' : '' }}">@icon('star') {{ trans('settings.app_features_security') }}</a>
<a href="{{ url('/settings/customization') }}" class="{{ $category === 'customization' ? 'active' : '' }}">@icon('palette') {{ trans('settings.app_customization') }}</a>
<a href="{{ url('/settings/registration') }}" class="{{ $category === 'registration' ? 'active' : '' }}">@icon('security') {{ trans('settings.reg_settings') }}</a>
<a href="{{ url('/settings/sorting') }}" class="{{ $category === 'sorting' ? 'active' : '' }}">@icon('sort') {{ trans('settings.sorting') }}</a>
</nav>
<h5 class="mt-xl">{{ trans('settings.system_version') }}</h5>
@ -29,6 +30,7 @@
<div class="card content-wrap auto-height">
@yield('card')
</div>
@yield('after-card')
</div>
</div>

View File

@ -0,0 +1,24 @@
@extends('layouts.simple')
@section('body')
<div class="container small">
@include('settings.parts.navbar', ['selected' => 'settings'])
<div class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('settings.sort_rule_create') }}</h1>
<form action="{{ url("/settings/sorting/rules") }}" method="POST">
{{ csrf_field() }}
@include('settings.sort-rules.parts.form', ['model' => null])
<div class="form-group text-right">
<a href="{{ url("/settings/sorting") }}" class="button outline">{{ trans('common.cancel') }}</a>
<button type="submit" class="button">{{ trans('common.save') }}</button>
</div>
</form>
</div>
</div>
@stop

View File

@ -0,0 +1,54 @@
@extends('layouts.simple')
@section('body')
<div class="container small">
@include('settings.parts.navbar', ['selected' => 'settings'])
<div class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('settings.sort_rule_edit') }}</h1>
<form action="{{ $rule->getUrl() }}" method="POST">
{{ method_field('PUT') }}
{{ csrf_field() }}
@include('settings.sort-rules.parts.form', ['model' => $rule])
<div class="form-group text-right">
<a href="{{ url("/settings/sorting") }}" class="button outline">{{ trans('common.cancel') }}</a>
<button type="submit" class="button">{{ trans('common.save') }}</button>
</div>
</form>
</div>
<div id="delete" class="card content-wrap auto-height">
<div class="flex-container-row items-center gap-l">
<div class="mb-m">
<h2 class="list-heading">{{ trans('settings.sort_rule_delete') }}</h2>
<p class="text-muted mb-xs">{{ trans('settings.sort_rule_delete_desc') }}</p>
@if($errors->has('delete'))
@foreach($errors->get('delete') as $error)
<p class="text-neg mb-xs">{{ $error }}</p>
@endforeach
@endif
</div>
<div class="flex">
<form action="{{ $rule->getUrl() }}" method="POST">
{{ method_field('DELETE') }}
{{ csrf_field() }}
@if($errors->has('delete'))
<input type="hidden" name="confirm" value="true">
@endif
<div class="text-right">
<button type="submit" class="button outline">{{ trans('common.delete') }}</button>
</div>
</form>
</div>
</div>
</div>
</div>
@stop

View File

@ -0,0 +1,56 @@
<div class="setting-list">
<div class="grid half">
<div>
<label class="setting-list-label">{{ trans('settings.sort_rule_details') }}</label>
<p class="text-muted text-small">{{ trans('settings.sort_rule_details_desc') }}</p>
</div>
<div>
<div class="form-group">
<label for="name">{{ trans('common.name') }}</label>
@include('form.text', ['name' => 'name'])
</div>
</div>
</div>
<div component="sort-rule-manager">
<label class="setting-list-label">{{ trans('settings.sort_rule_operations') }}</label>
<p class="text-muted text-small">{{ trans('settings.sort_rule_operations_desc') }}</p>
@include('form.errors', ['name' => 'sequence'])
<input refs="sort-rule-manager@input" type="hidden" name="sequence"
value="{{ old('sequence') ?? $model?->sequence ?? '' }}">
@php
$configuredOps = old('sequence') ? \BookStack\Sorting\SortRuleOperation::fromSequence(old('sequence')) : ($model?->getOperations() ?? []);
@endphp
<div class="grid half">
<div class="form-group">
<label for="books"
id="sort-rule-configured-operations">{{ trans('settings.sort_rule_configured_operations') }}</label>
<ul refs="sort-rule-manager@configured-operations-list"
aria-labelledby="sort-rule-configured-operations"
class="scroll-box configured-option-list">
<li class="text-muted empty-state px-m py-s italic text-small">{{ trans('settings.sort_rule_configured_operations_empty') }}</li>
@foreach($configuredOps as $operation)
@include('settings.sort-rules.parts.operation', ['operation' => $operation])
@endforeach
</ul>
</div>
<div class="form-group">
<label for="books"
id="sort-rule-available-operations">{{ trans('settings.sort_rule_available_operations') }}</label>
<ul refs="sort-rule-manager@available-operations-list"
aria-labelledby="sort-rule-available-operations"
class="scroll-box available-option-list">
<li class="text-muted empty-state px-m py-s italic text-small">{{ trans('settings.sort_rule_available_operations_empty') }}</li>
@foreach(\BookStack\Sorting\SortRuleOperation::allExcluding($configuredOps) as $operation)
@include('settings.sort-rules.parts.operation', ['operation' => $operation])
@endforeach
</ul>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
<li data-id="{{ $operation->value }}"
class="scroll-box-item items-center">
<div class="handle px-s">@icon('grip')</div>
<div class="text-small">{{ $operation->getLabel() }}</div>
<div class="buttons flex-container-row items-center ml-auto px-xxs py-xxs">
<button type="button" data-action="move_up" class="icon-button p-xxs"
title="{{ trans('entities.books_sort_move_up') }}">@icon('chevron-up')</button>
<button type="button" data-action="move_down" class="icon-button p-xxs"
title="{{ trans('entities.books_sort_move_down') }}">@icon('chevron-down')</button>
<button type="button" data-action="remove" class="icon-button p-xxs"
title="{{ trans('common.remove') }}">@icon('remove')</button>
<button type="button" data-action="add" class="icon-button p-xxs"
title="{{ trans('common.add') }}">@icon('add-small')</button>
</div>
</li>

View File

@ -0,0 +1,12 @@
<div class="item-list-row flex-container-row py-xs px-m gap-m items-center">
<div class="py-xs flex">
<a href="{{ $rule->getUrl() }}">{{ $rule->name }}</a>
</div>
<div class="px-m text-small text-muted ml-auto">
{{ implode(', ', array_map(fn ($op) => $op->getLabel(), $rule->getOperations())) }}
</div>
<div>
<span title="{{ trans_choice('settings.sort_rule_assigned_to_x_books', $rule->books_count ?? 0) }}"
class="flex fill-area min-width-xxs bold text-right text-book"><span class="opacity-60">@icon('book')</span>{{ $rule->books_count ?? 0 }}</span>
</div>
</div>

View File

@ -38,7 +38,7 @@
</div>
<ul refs="shelf-sort@shelf-book-list"
aria-labelledby="shelf-sort-books-label"
class="scroll-box">
class="scroll-box configured-option-list">
@foreach (($shelf->visibleBooks ?? []) as $book)
@include('shelves.parts.shelf-sort-book-item', ['book' => $book])
@endforeach
@ -49,7 +49,7 @@
<input type="text" refs="shelf-sort@book-search" class="scroll-box-search" placeholder="{{ trans('common.search') }}">
<ul refs="shelf-sort@all-book-list"
aria-labelledby="shelf-sort-all-books-label"
class="scroll-box">
class="scroll-box available-option-list">
@foreach ($books as $book)
@include('shelves.parts.shelf-sort-book-item', ['book' => $book])
@endforeach

View File

@ -13,6 +13,7 @@ use BookStack\Permissions\PermissionsController;
use BookStack\References\ReferenceController;
use BookStack\Search\SearchController;
use BookStack\Settings as SettingControllers;
use BookStack\Sorting as SortingControllers;
use BookStack\Theming\ThemeController;
use BookStack\Uploads\Controllers as UploadControllers;
use BookStack\Users\Controllers as UserControllers;
@ -66,7 +67,7 @@ Route::middleware('auth')->group(function () {
Route::get('/books/{slug}/edit', [EntityControllers\BookController::class, 'edit']);
Route::put('/books/{slug}', [EntityControllers\BookController::class, 'update']);
Route::delete('/books/{id}', [EntityControllers\BookController::class, 'destroy']);
Route::get('/books/{slug}/sort-item', [EntityControllers\BookSortController::class, 'showItem']);
Route::get('/books/{slug}/sort-item', [SortingControllers\BookSortController::class, 'showItem']);
Route::get('/books/{slug}', [EntityControllers\BookController::class, 'show']);
Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']);
Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']);
@ -74,8 +75,8 @@ Route::middleware('auth')->group(function () {
Route::get('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'showCopy']);
Route::post('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'copy']);
Route::post('/books/{bookSlug}/convert-to-shelf', [EntityControllers\BookController::class, 'convertToShelf']);
Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']);
Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']);
Route::get('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'show']);
Route::put('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'update']);
Route::get('/books/{slug}/references', [ReferenceController::class, 'book']);
Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']);
Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']);
@ -294,6 +295,13 @@ Route::middleware('auth')->group(function () {
Route::get('/settings/webhooks/{id}/delete', [ActivityControllers\WebhookController::class, 'delete']);
Route::delete('/settings/webhooks/{id}', [ActivityControllers\WebhookController::class, 'destroy']);
// Sort Rules
Route::get('/settings/sorting/rules/new', [SortingControllers\SortRuleController::class, 'create']);
Route::post('/settings/sorting/rules', [SortingControllers\SortRuleController::class, 'store']);
Route::get('/settings/sorting/rules/{id}', [SortingControllers\SortRuleController::class, 'edit']);
Route::put('/settings/sorting/rules/{id}', [SortingControllers\SortRuleController::class, 'update']);
Route::delete('/settings/sorting/rules/{id}', [SortingControllers\SortRuleController::class, 'destroy']);
// Settings
Route::get('/settings', [SettingControllers\SettingController::class, 'index'])->name('settings');
Route::get('/settings/{category}', [SettingControllers\SettingController::class, 'category'])->name('settings.category');

View File

@ -0,0 +1,112 @@
<?php
namespace Commands;
use BookStack\Entities\Models\Book;
use BookStack\Sorting\SortRule;
use Tests\TestCase;
class AssignSortRuleCommandTest extends TestCase
{
public function test_no_given_sort_rule_lists_options()
{
$sortRules = SortRule::factory()->createMany(10);
$commandRun = $this->artisan('bookstack:assign-sort-rule')
->expectsOutputToContain('Sort rule ID required!')
->assertExitCode(1);
foreach ($sortRules as $sortRule) {
$commandRun->expectsOutputToContain("{$sortRule->id}: {$sortRule->name}");
}
}
public function test_run_without_options_advises_help()
{
$this->artisan("bookstack:assign-sort-rule 100")
->expectsOutput("No option provided to specify target. Run with the -h option to see all available options.")
->assertExitCode(1);
}
public function test_run_without_valid_sort_advises_help()
{
$this->artisan("bookstack:assign-sort-rule 100342 --all-books")
->expectsOutput("Sort rule of provided id 100342 not found!")
->assertExitCode(1);
}
public function test_confirmation_required()
{
$sortRule = SortRule::factory()->create();
$this->artisan("bookstack:assign-sort-rule {$sortRule->id} --all-books")
->expectsConfirmation('Are you sure you want to continue?', 'no')
->assertExitCode(1);
$booksWithSort = Book::query()->whereNotNull('sort_rule_id')->count();
$this->assertEquals(0, $booksWithSort);
}
public function test_assign_to_all_books()
{
$sortRule = SortRule::factory()->create();
$booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
$this->assertGreaterThan(0, $booksWithoutSort);
$this->artisan("bookstack:assign-sort-rule {$sortRule->id} --all-books")
->expectsOutputToContain("This will apply sort rule [{$sortRule->id}: {$sortRule->name}] to {$booksWithoutSort} book(s)")
->expectsConfirmation('Are you sure you want to continue?', 'yes')
->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)")
->assertExitCode(0);
$booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
$this->assertEquals(0, $booksWithoutSort);
}
public function test_assign_to_all_books_without_sort()
{
$totalBooks = Book::query()->count();
$book = $this->entities->book();
$sortRuleA = SortRule::factory()->create();
$sortRuleB = SortRule::factory()->create();
$book->sort_rule_id = $sortRuleA->id;
$book->save();
$booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
$this->assertEquals($totalBooks, $booksWithoutSort + 1);
$this->artisan("bookstack:assign-sort-rule {$sortRuleB->id} --books-without-sort")
->expectsConfirmation('Are you sure you want to continue?', 'yes')
->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)")
->assertExitCode(0);
$booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
$this->assertEquals(0, $booksWithoutSort);
$this->assertEquals($totalBooks, $sortRuleB->books()->count() + 1);
}
public function test_assign_to_all_books_with_sort()
{
$book = $this->entities->book();
$sortRuleA = SortRule::factory()->create();
$sortRuleB = SortRule::factory()->create();
$book->sort_rule_id = $sortRuleA->id;
$book->save();
$this->artisan("bookstack:assign-sort-rule {$sortRuleB->id} --books-with-sort={$sortRuleA->id}")
->expectsConfirmation('Are you sure you want to continue?', 'yes')
->expectsOutputToContain("Sort applied to 1 book(s)")
->assertExitCode(0);
$book->refresh();
$this->assertEquals($sortRuleB->id, $book->sort_rule_id);
$this->assertEquals(1, $sortRuleB->books()->count());
}
public function test_assign_to_all_books_with_sort_id_is_validated()
{
$this->artisan("bookstack:assign-sort-rule 50 --books-with-sort=beans")
->expectsOutputToContain("Provided --books-with-sort option value is invalid")
->assertExitCode(1);
}
}

View File

@ -300,7 +300,7 @@ class PageTest extends TestCase
]);
$resp = $this->asAdmin()->get('/pages/recently-updated');
$this->withHtml($resp)->assertElementContains('.entity-list .page:nth-child(1)', 'Updated 0 seconds ago by ' . $user->name);
$this->withHtml($resp)->assertElementContains('.entity-list .page:nth-child(1) small', 'by ' . $user->name);
}
public function test_recently_updated_pages_view_shows_parent_chain()

View File

@ -1,239 +1,15 @@
<?php
namespace Tests\Entity;
namespace Sorting;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Sorting\SortRule;
use Tests\TestCase;
class SortTest extends TestCase
class BookSortTest extends TestCase
{
public function test_drafts_do_not_show_up()
{
$this->asAdmin();
$pageRepo = app(PageRepo::class);
$book = $this->entities->book();
$draft = $pageRepo->getNewDraftPage($book);
$resp = $this->get($book->getUrl());
$resp->assertSee($draft->name);
$resp = $this->get($book->getUrl() . '/sort');
$resp->assertDontSee($draft->name);
}
public function test_page_move_into_book()
{
$page = $this->entities->page();
$currentBook = $page->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$resp = $this->asEditor()->get($page->getUrl('/move'));
$resp->assertSee('Move Page');
$movePageResp = $this->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
$newBookResp = $this->get($newBook->getUrl());
$newBookResp->assertSee('moved page');
$newBookResp->assertSee($page->name);
}
public function test_page_move_into_chapter()
{
$page = $this->entities->page();
$currentBook = $page->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$newChapter = $newBook->chapters()->first();
$movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [
'entity_selection' => 'chapter:' . $newChapter->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new chapter');
$newChapterResp = $this->get($newChapter->getUrl());
$newChapterResp->assertSee($page->name);
}
public function test_page_move_from_chapter_to_book()
{
$oldChapter = Chapter::query()->first();
$page = $oldChapter->pages()->first();
$newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first();
$movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book');
$this->assertTrue($page->chapter === null, 'Page has no parent chapter');
$newBookResp = $this->get($newBook->getUrl());
$newBookResp->assertSee($page->name);
}
public function test_page_move_requires_create_permissions_on_parent()
{
$page = $this->entities->page();
$currentBook = $page->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$editor = $this->users->editor();
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all());
$movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$this->assertPermissionError($movePageResp);
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all());
$movePageResp = $this->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
}
public function test_page_move_requires_delete_permissions()
{
$page = $this->entities->page();
$currentBook = $page->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$editor = $this->users->editor();
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all());
$this->permissions->setEntityPermissions($page, ['view', 'update', 'create'], $editor->roles->all());
$movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$this->assertPermissionError($movePageResp);
$pageView = $this->get($page->getUrl());
$pageView->assertDontSee($page->getUrl('/move'));
$this->permissions->setEntityPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all());
$movePageResp = $this->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
}
public function test_chapter_move()
{
$chapter = $this->entities->chapter();
$currentBook = $chapter->book;
$pageToCheck = $chapter->pages->first();
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move'));
$chapterMoveResp->assertSee('Move Chapter');
$moveChapterResp = $this->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$chapter = Chapter::query()->find($chapter->id);
$moveChapterResp->assertRedirect($chapter->getUrl());
$this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book');
$newBookResp = $this->get($newBook->getUrl());
$newBookResp->assertSee('moved chapter');
$newBookResp->assertSee($chapter->name);
$pageToCheck = Page::query()->find($pageToCheck->id);
$this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book');
$pageCheckResp = $this->get($pageToCheck->getUrl());
$pageCheckResp->assertSee($newBook->name);
}
public function test_chapter_move_requires_delete_permissions()
{
$chapter = $this->entities->chapter();
$currentBook = $chapter->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$editor = $this->users->editor();
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all());
$this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all());
$moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$this->assertPermissionError($moveChapterResp);
$pageView = $this->get($chapter->getUrl());
$pageView->assertDontSee($chapter->getUrl('/move'));
$this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all());
$moveChapterResp = $this->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$chapter = Chapter::query()->find($chapter->id);
$moveChapterResp->assertRedirect($chapter->getUrl());
$this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book');
}
public function test_chapter_move_requires_create_permissions_in_new_book()
{
$chapter = $this->entities->chapter();
$currentBook = $chapter->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$editor = $this->users->editor();
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]);
$this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]);
$moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$this->assertPermissionError($moveChapterResp);
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]);
$moveChapterResp = $this->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$chapter = Chapter::query()->find($chapter->id);
$moveChapterResp->assertRedirect($chapter->getUrl());
$this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book');
}
public function test_chapter_move_changes_book_for_deleted_pages_within()
{
/** @var Chapter $chapter */
$chapter = Chapter::query()->whereHas('pages')->first();
$currentBook = $chapter->book;
$pageToCheck = $chapter->pages->first();
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$pageToCheck->delete();
$this->asEditor()->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$pageToCheck->refresh();
$this->assertEquals($newBook->id, $pageToCheck->book_id);
}
public function test_book_sort_page_shows()
{
$bookToSort = $this->entities->book();
@ -246,6 +22,20 @@ class SortTest extends TestCase
$resp->assertSee($bookToSort->name);
}
public function test_drafts_do_not_show_up()
{
$this->asAdmin();
$pageRepo = app(PageRepo::class);
$book = $this->entities->book();
$draft = $pageRepo->getNewDraftPage($book);
$resp = $this->get($book->getUrl());
$resp->assertSee($draft->name);
$resp = $this->get($book->getUrl('/sort'));
$resp->assertDontSee($draft->name);
}
public function test_book_sort()
{
$oldBook = $this->entities->book();
@ -417,13 +207,39 @@ class SortTest extends TestCase
]);
}
public function test_book_sort_does_not_change_timestamps_on_just_order_changes()
{
$book = $this->entities->bookHasChaptersAndPages();
$chapter = $book->chapters()->first();
\DB::table('chapters')->where('id', '=', $chapter->id)->update([
'priority' => 10001,
'updated_at' => \Carbon\Carbon::now()->subYear(5),
]);
$chapter->refresh();
$oldUpdatedAt = $chapter->updated_at->unix();
$sortData = [
'id' => $chapter->id,
'sort' => 0,
'parentChapter' => false,
'type' => 'chapter',
'book' => $book->id,
];
$this->asEditor()->put($book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
$chapter->refresh();
$this->assertNotEquals(10001, $chapter->priority);
$this->assertEquals($oldUpdatedAt, $chapter->updated_at->unix());
}
public function test_book_sort_item_returns_book_content()
{
$bookToSort = $this->entities->book();
$firstPage = $bookToSort->pages[0];
$firstChapter = $bookToSort->chapters[0];
$resp = $this->asAdmin()->get($bookToSort->getUrl() . '/sort-item');
$resp = $this->asAdmin()->get($bookToSort->getUrl('/sort-item'));
// Ensure book details are returned
$resp->assertSee($bookToSort->name);
@ -431,6 +247,53 @@ class SortTest extends TestCase
$resp->assertSee($firstChapter->name);
}
public function test_book_sort_item_shows_auto_sort_status()
{
$sort = SortRule::factory()->create(['name' => 'My sort']);
$book = $this->entities->book();
$resp = $this->asAdmin()->get($book->getUrl('/sort-item'));
$this->withHtml($resp)->assertElementNotExists("span[title='Auto Sort Active: My sort']");
$book->sort_rule_id = $sort->id;
$book->save();
$resp = $this->asAdmin()->get($book->getUrl('/sort-item'));
$this->withHtml($resp)->assertElementExists("span[title='Auto Sort Active: My sort']");
}
public function test_auto_sort_options_shown_on_sort_page()
{
$sort = SortRule::factory()->create();
$book = $this->entities->book();
$resp = $this->asAdmin()->get($book->getUrl('/sort'));
$this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"]');
}
public function test_auto_sort_option_submit_saves_to_book()
{
$sort = SortRule::factory()->create();
$book = $this->entities->book();
$bookPage = $book->pages()->first();
$bookPage->priority = 10000;
$bookPage->save();
$resp = $this->asAdmin()->put($book->getUrl('/sort'), [
'auto-sort' => $sort->id,
]);
$resp->assertRedirect($book->getUrl());
$book->refresh();
$bookPage->refresh();
$this->assertEquals($sort->id, $book->sort_rule_id);
$this->assertNotEquals(10000, $bookPage->priority);
$resp = $this->get($book->getUrl('/sort'));
$this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"][selected]');
}
public function test_pages_in_book_show_sorted_by_priority()
{
$book = $this->entities->bookHasChaptersAndPages();

221
tests/Sorting/MoveTest.php Normal file
View File

@ -0,0 +1,221 @@
<?php
namespace Sorting;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Tests\TestCase;
class MoveTest extends TestCase
{
public function test_page_move_into_book()
{
$page = $this->entities->page();
$currentBook = $page->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$resp = $this->asEditor()->get($page->getUrl('/move'));
$resp->assertSee('Move Page');
$movePageResp = $this->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
$newBookResp = $this->get($newBook->getUrl());
$newBookResp->assertSee('moved page');
$newBookResp->assertSee($page->name);
}
public function test_page_move_into_chapter()
{
$page = $this->entities->page();
$currentBook = $page->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$newChapter = $newBook->chapters()->first();
$movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [
'entity_selection' => 'chapter:' . $newChapter->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new chapter');
$newChapterResp = $this->get($newChapter->getUrl());
$newChapterResp->assertSee($page->name);
}
public function test_page_move_from_chapter_to_book()
{
$oldChapter = Chapter::query()->first();
$page = $oldChapter->pages()->first();
$newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first();
$movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book');
$this->assertTrue($page->chapter === null, 'Page has no parent chapter');
$newBookResp = $this->get($newBook->getUrl());
$newBookResp->assertSee($page->name);
}
public function test_page_move_requires_create_permissions_on_parent()
{
$page = $this->entities->page();
$currentBook = $page->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$editor = $this->users->editor();
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all());
$movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$this->assertPermissionError($movePageResp);
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all());
$movePageResp = $this->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
}
public function test_page_move_requires_delete_permissions()
{
$page = $this->entities->page();
$currentBook = $page->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$editor = $this->users->editor();
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all());
$this->permissions->setEntityPermissions($page, ['view', 'update', 'create'], $editor->roles->all());
$movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$this->assertPermissionError($movePageResp);
$pageView = $this->get($page->getUrl());
$pageView->assertDontSee($page->getUrl('/move'));
$this->permissions->setEntityPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all());
$movePageResp = $this->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
}
public function test_chapter_move()
{
$chapter = $this->entities->chapter();
$currentBook = $chapter->book;
$pageToCheck = $chapter->pages->first();
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move'));
$chapterMoveResp->assertSee('Move Chapter');
$moveChapterResp = $this->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$chapter = Chapter::query()->find($chapter->id);
$moveChapterResp->assertRedirect($chapter->getUrl());
$this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book');
$newBookResp = $this->get($newBook->getUrl());
$newBookResp->assertSee('moved chapter');
$newBookResp->assertSee($chapter->name);
$pageToCheck = Page::query()->find($pageToCheck->id);
$this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book');
$pageCheckResp = $this->get($pageToCheck->getUrl());
$pageCheckResp->assertSee($newBook->name);
}
public function test_chapter_move_requires_delete_permissions()
{
$chapter = $this->entities->chapter();
$currentBook = $chapter->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$editor = $this->users->editor();
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all());
$this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all());
$moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$this->assertPermissionError($moveChapterResp);
$pageView = $this->get($chapter->getUrl());
$pageView->assertDontSee($chapter->getUrl('/move'));
$this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all());
$moveChapterResp = $this->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$chapter = Chapter::query()->find($chapter->id);
$moveChapterResp->assertRedirect($chapter->getUrl());
$this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book');
}
public function test_chapter_move_requires_create_permissions_in_new_book()
{
$chapter = $this->entities->chapter();
$currentBook = $chapter->book;
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$editor = $this->users->editor();
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]);
$this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]);
$moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$this->assertPermissionError($moveChapterResp);
$this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]);
$moveChapterResp = $this->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$chapter = Chapter::query()->find($chapter->id);
$moveChapterResp->assertRedirect($chapter->getUrl());
$this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book');
}
public function test_chapter_move_changes_book_for_deleted_pages_within()
{
/** @var Chapter $chapter */
$chapter = Chapter::query()->whereHas('pages')->first();
$currentBook = $chapter->book;
$pageToCheck = $chapter->pages->first();
$newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
$pageToCheck->delete();
$this->asEditor()->put($chapter->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
]);
$pageToCheck->refresh();
$this->assertEquals($newBook->id, $pageToCheck->book_id);
}
}

View File

@ -0,0 +1,221 @@
<?php
namespace Sorting;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Sorting\SortRule;
use Tests\Api\TestsApi;
use Tests\TestCase;
class SortRuleTest extends TestCase
{
use TestsApi;
public function test_manage_settings_permission_required()
{
$rule = SortRule::factory()->create();
$user = $this->users->viewer();
$this->actingAs($user);
$actions = [
['GET', '/settings/sorting'],
['POST', '/settings/sorting/rules'],
['GET', "/settings/sorting/rules/{$rule->id}"],
['PUT', "/settings/sorting/rules/{$rule->id}"],
['DELETE', "/settings/sorting/rules/{$rule->id}"],
];
foreach ($actions as [$method, $path]) {
$resp = $this->call($method, $path);
$this->assertPermissionError($resp);
}
$this->permissions->grantUserRolePermissions($user, ['settings-manage']);
foreach ($actions as [$method, $path]) {
$resp = $this->call($method, $path);
$this->assertNotPermissionError($resp);
}
}
public function test_create_flow()
{
$resp = $this->asAdmin()->get('/settings/sorting');
$this->withHtml($resp)->assertLinkExists(url('/settings/sorting/rules/new'));
$resp = $this->get('/settings/sorting/rules/new');
$this->withHtml($resp)->assertElementExists('form[action$="/settings/sorting/rules"] input[name="name"]');
$resp->assertSeeText('Name - Alphabetical (Asc)');
$details = ['name' => 'My new sort', 'sequence' => 'name_asc'];
$resp = $this->post('/settings/sorting/rules', $details);
$resp->assertRedirect('/settings/sorting');
$this->assertActivityExists(ActivityType::SORT_RULE_CREATE);
$this->assertDatabaseHas('sort_rules', $details);
}
public function test_listing_in_settings()
{
$rule = SortRule::factory()->create(['name' => 'My super sort rule', 'sequence' => 'name_asc']);
$books = Book::query()->limit(5)->get();
foreach ($books as $book) {
$book->sort_rule_id = $rule->id;
$book->save();
}
$resp = $this->asAdmin()->get('/settings/sorting');
$resp->assertSeeText('My super sort rule');
$resp->assertSeeText('Name - Alphabetical (Asc)');
$this->withHtml($resp)->assertElementContains('.item-list-row [title="Assigned to 5 Books"]', '5');
}
public function test_update_flow()
{
$rule = SortRule::factory()->create(['name' => 'My sort rule to update', 'sequence' => 'name_asc']);
$resp = $this->asAdmin()->get("/settings/sorting/rules/{$rule->id}");
$respHtml = $this->withHtml($resp);
$respHtml->assertElementContains('.configured-option-list', 'Name - Alphabetical (Asc)');
$respHtml->assertElementNotContains('.available-option-list', 'Name - Alphabetical (Asc)');
$updateData = ['name' => 'My updated sort', 'sequence' => 'name_desc,chapters_last'];
$resp = $this->put("/settings/sorting/rules/{$rule->id}", $updateData);
$resp->assertRedirect('/settings/sorting');
$this->assertActivityExists(ActivityType::SORT_RULE_UPDATE);
$this->assertDatabaseHas('sort_rules', $updateData);
}
public function test_update_triggers_resort_on_assigned_books()
{
$book = $this->entities->bookHasChaptersAndPages();
$chapter = $book->chapters()->first();
$rule = SortRule::factory()->create(['name' => 'My sort rule to update', 'sequence' => 'name_asc']);
$book->sort_rule_id = $rule->id;
$book->save();
$chapter->priority = 10000;
$chapter->save();
$resp = $this->asAdmin()->put("/settings/sorting/rules/{$rule->id}", ['name' => $rule->name, 'sequence' => 'chapters_last']);
$resp->assertRedirect('/settings/sorting');
$chapter->refresh();
$this->assertNotEquals(10000, $chapter->priority);
}
public function test_delete_flow()
{
$rule = SortRule::factory()->create();
$resp = $this->asAdmin()->get("/settings/sorting/rules/{$rule->id}");
$resp->assertSeeText('Delete Sort Rule');
$resp = $this->delete("settings/sorting/rules/{$rule->id}");
$resp->assertRedirect('/settings/sorting');
$this->assertActivityExists(ActivityType::SORT_RULE_DELETE);
$this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]);
}
public function test_delete_requires_confirmation_if_books_assigned()
{
$rule = SortRule::factory()->create();
$books = Book::query()->limit(5)->get();
foreach ($books as $book) {
$book->sort_rule_id = $rule->id;
$book->save();
}
$resp = $this->asAdmin()->get("/settings/sorting/rules/{$rule->id}");
$resp->assertSeeText('Delete Sort Rule');
$resp = $this->delete("settings/sorting/rules/{$rule->id}");
$resp->assertRedirect("/settings/sorting/rules/{$rule->id}#delete");
$resp = $this->followRedirects($resp);
$resp->assertSeeText('This sort rule is currently used on 5 book(s). Are you sure you want to delete this?');
$this->assertDatabaseHas('sort_rules', ['id' => $rule->id]);
$resp = $this->delete("settings/sorting/rules/{$rule->id}", ['confirm' => 'true']);
$resp->assertRedirect('/settings/sorting');
$this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]);
$this->assertDatabaseMissing('books', ['sort_rule_id' => $rule->id]);
}
public function test_page_create_triggers_book_sort()
{
$book = $this->entities->bookHasChaptersAndPages();
$rule = SortRule::factory()->create(['sequence' => 'name_asc,chapters_first']);
$book->sort_rule_id = $rule->id;
$book->save();
$resp = $this->actingAsApiEditor()->post("/api/pages", [
'book_id' => $book->id,
'name' => '1111 page',
'markdown' => 'Hi'
]);
$resp->assertOk();
$this->assertDatabaseHas('pages', [
'book_id' => $book->id,
'name' => '1111 page',
'priority' => $book->chapters()->count() + 1,
]);
}
public function test_auto_book_sort_does_not_touch_timestamps()
{
$book = $this->entities->bookHasChaptersAndPages();
$rule = SortRule::factory()->create(['sequence' => 'name_asc,chapters_first']);
$book->sort_rule_id = $rule->id;
$book->save();
$page = $book->pages()->first();
$chapter = $book->chapters()->first();
$resp = $this->actingAsApiEditor()->put("/api/pages/{$page->id}", [
'name' => '1111 page',
]);
$resp->assertOk();
$oldTime = $chapter->updated_at->unix();
$oldPriority = $chapter->priority;
$chapter->refresh();
$this->assertEquals($oldTime, $chapter->updated_at->unix());
$this->assertNotEquals($oldPriority, $chapter->priority);
}
public function test_name_numeric_ordering()
{
$book = Book::factory()->create();
$rule = SortRule::factory()->create(['sequence' => 'name_numeric_asc']);
$book->sort_rule_id = $rule->id;
$book->save();
$this->permissions->regenerateForEntity($book);
$namesToAdd = [
"1 - Pizza",
"2.0 - Tomato",
"2.5 - Beans",
"10 - Bread",
"20 - Milk",
];
foreach ($namesToAdd as $name) {
$this->actingAsApiEditor()->post("/api/pages", [
'book_id' => $book->id,
'name' => $name,
'markdown' => 'Hello'
]);
}
foreach ($namesToAdd as $index => $name) {
$this->assertDatabaseHas('pages', [
'book_id' => $book->id,
'name' => $name,
'priority' => $index + 1,
]);
}
}
}