diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 5ec9b9cf0..a7f129f71 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -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. */ diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php index 641106d7f..66ca30197 100644 --- a/app/Activity/Controllers/AuditLogController.php +++ b/app/Activity/Controllers/AuditLogController.php @@ -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'))) ]); } } diff --git a/app/App/helpers.php b/app/App/helpers.php index 941c267d6..204b3f06a 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -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)); -} diff --git a/app/Console/Commands/AssignSortRuleCommand.php b/app/Console/Commands/AssignSortRuleCommand.php new file mode 100644 index 000000000..c438d0783 --- /dev/null +++ b/app/Console/Commands/AssignSortRuleCommand.php @@ -0,0 +1,99 @@ +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; + } +} diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index c1644dcf5..ede4fc7d5 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -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. */ diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 033350743..151d5b055 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -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))) { diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 19d159eb1..92e6a81c3 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -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; } diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 17cbccd41..fdf2de4e2 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -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; } } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 68b1c398f..c3be6d826 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -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; } diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 7fa2134b7..7dd3f3e11 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -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 $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 - */ - 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; - } } diff --git a/app/Entities/Controllers/BookSortController.php b/app/Sorting/BookSortController.php similarity index 54% rename from app/Entities/Controllers/BookSortController.php rename to app/Sorting/BookSortController.php index 5aefc5832..479d19724 100644 --- a/app/Entities/Controllers/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -1,11 +1,10 @@ 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()); diff --git a/app/Entities/Tools/BookSortMap.php b/app/Sorting/BookSortMap.php similarity index 96% rename from app/Entities/Tools/BookSortMap.php rename to app/Sorting/BookSortMap.php index ff1ec767f..96c9d342a 100644 --- a/app/Entities/Tools/BookSortMap.php +++ b/app/Sorting/BookSortMap.php @@ -1,6 +1,6 @@ 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 $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 + */ + 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; + } +} diff --git a/app/Sorting/SortRule.php b/app/Sorting/SortRule.php new file mode 100644 index 000000000..45e5514fd --- /dev/null +++ b/app/Sorting/SortRule.php @@ -0,0 +1,63 @@ +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(); + } +} diff --git a/app/Sorting/SortRuleController.php b/app/Sorting/SortRuleController.php new file mode 100644 index 000000000..96b8e8ef5 --- /dev/null +++ b/app/Sorting/SortRuleController.php @@ -0,0 +1,114 @@ +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'); + } +} diff --git a/app/Sorting/SortRuleOperation.php b/app/Sorting/SortRuleOperation.php new file mode 100644 index 000000000..0d8ff239f --- /dev/null +++ b/app/Sorting/SortRuleOperation.php @@ -0,0 +1,68 @@ +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); + } +} diff --git a/app/Sorting/SortSetOperationComparisons.php b/app/Sorting/SortSetOperationComparisons.php new file mode 100644 index 000000000..e1c3e625f --- /dev/null +++ b/app/Sorting/SortSetOperationComparisons.php @@ -0,0 +1,69 @@ +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); + } +} diff --git a/app/Sorting/SortUrl.php b/app/Sorting/SortUrl.php new file mode 100644 index 000000000..f01df2c36 --- /dev/null +++ b/app/Sorting/SortUrl.php @@ -0,0 +1,49 @@ +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)); + } +} diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php index 9cb8e971c..48d43d7a8 100644 --- a/database/factories/Entities/Models/BookFactory.php +++ b/database/factories/Entities/Models/BookFactory.php @@ -26,7 +26,9 @@ class BookFactory extends Factory 'name' => $this->faker->sentence(), 'slug' => Str::random(10), 'description' => $description, - 'description_html' => '

' . e($description) . '

' + 'description_html' => '

' . e($description) . '

', + 'sort_rule_id' => null, + 'default_template_id' => null, ]; } } diff --git a/database/factories/Sorting/SortRuleFactory.php b/database/factories/Sorting/SortRuleFactory.php new file mode 100644 index 000000000..dafe8c3fa --- /dev/null +++ b/database/factories/Sorting/SortRuleFactory.php @@ -0,0 +1,30 @@ + $op->name . ' Sort', + 'sequence' => $op->value, + ]; + } +} diff --git a/database/migrations/2025_01_29_180933_create_sort_rules_table.php b/database/migrations/2025_01_29_180933_create_sort_rules_table.php new file mode 100644 index 000000000..37d20ddf6 --- /dev/null +++ b/database/migrations/2025_01_29_180933_create_sort_rules_table.php @@ -0,0 +1,29 @@ +increments('id'); + $table->string('name'); + $table->text('sequence'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sort_rules'); + } +}; diff --git a/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php b/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php new file mode 100644 index 000000000..106db05ca --- /dev/null +++ b/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/lang/en/activities.php b/lang/en/activities.php index 7c3454d41..67df53e36 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -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', ]; diff --git a/lang/en/entities.php b/lang/en/entities.php index 26a563a7e..a74785eaa 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -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', diff --git a/lang/en/settings.php b/lang/en/settings.php index c0b6b692a..098479f3b 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -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.
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', diff --git a/package-lock.json b/package-lock.json index 1912106c2..44a735d2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 08af25d14..4571ea77d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/icons/auto-sort.svg b/resources/icons/auto-sort.svg new file mode 100644 index 000000000..c3cb2f516 --- /dev/null +++ b/resources/icons/auto-sort.svg @@ -0,0 +1 @@ + diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 12c991a51..10b8025db 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -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'; diff --git a/resources/js/components/shelf-sort.js b/resources/js/components/shelf-sort.js index 01ca11a33..b56b01980 100644 --- a/resources/js/components/shelf-sort.js +++ b/resources/js/components/shelf-sort.js @@ -1,29 +1,6 @@ import Sortable from 'sortablejs'; import {Component} from './component'; - -/** - * @type {Object} - */ -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(','); diff --git a/resources/js/components/sort-rule-manager.ts b/resources/js/components/sort-rule-manager.ts new file mode 100644 index 000000000..ff08f4ab8 --- /dev/null +++ b/resources/js/components/sort-rule-manager.ts @@ -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(','); + } +} \ No newline at end of file diff --git a/resources/js/services/dual-lists.ts b/resources/js/services/dual-lists.ts new file mode 100644 index 000000000..98f2af92d --- /dev/null +++ b/resources/js/services/dual-lists.ts @@ -0,0 +1,51 @@ +/** + * Service for helping manage common dual-list scenarios. + * (Shelf book manager, sort set manager). + */ + +type ListActionsSet = Record 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(); + } + }; +} + diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 888b32527..58d39d3ee 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -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; } \ No newline at end of file diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index fd76f498e..1e503dd0f 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -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: ''; diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 03998e261..6fdb1819e 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -8,14 +8,24 @@ @icon('book') {{ $book->name }} +
+ @if($book->sortRule) + @icon('auto-sort') + @endif +
- - - - - + + + + +
    diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index c82ad4e3b..e090708b1 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -18,14 +18,38 @@

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

    -

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

    + +
    +

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

    +
    + @php + $autoSortVal = intval(old('auto-sort') ?? $book->sort_rule_id ?? 0); + @endphp + + +
    +
    @include('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren])
    -
    - {!! csrf_field() !!} + + {{ csrf_field() }}
    diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 28cdeb8a5..8e4776680 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -26,11 +26,11 @@ class="input-base text-left">{{ $filters['event'] ?: trans('settings.audit_event_filter_no_filter') }} diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php new file mode 100644 index 000000000..9d1d9814b --- /dev/null +++ b/resources/views/settings/categories/sorting.blade.php @@ -0,0 +1,68 @@ +@extends('settings.layout') + +@php + $sortRules = \BookStack\Sorting\SortRule::allByName(); +@endphp + +@section('card') +

    {{ trans('settings.sorting') }}

    + + {{ csrf_field() }} + + +
    +
    +
    + +

    {{ trans('settings.sorting_book_default_desc') }}

    +
    +
    + +
    +
    + +
    + +
    + +
    + +@endsection + +@section('after-card') +
    +
    +
    +

    {{ trans('settings.sorting_rules') }}

    +

    {{ trans('settings.sorting_rules_desc') }}

    +
    + +
    + + @if(empty($sortRules)) +

    {{ trans('common.no_items') }}

    + @else +
    + @foreach($sortRules as $rule) + @include('settings.sort-rules.parts.sort-rule-list-item', ['rule' => $rule]) + @endforeach +
    + @endif +
    +@endsection \ No newline at end of file diff --git a/resources/views/settings/layout.blade.php b/resources/views/settings/layout.blade.php index a59b58d53..930d407a5 100644 --- a/resources/views/settings/layout.blade.php +++ b/resources/views/settings/layout.blade.php @@ -13,6 +13,7 @@ @icon('star') {{ trans('settings.app_features_security') }} @icon('palette') {{ trans('settings.app_customization') }} @icon('security') {{ trans('settings.reg_settings') }} + @icon('sort') {{ trans('settings.sorting') }}
    {{ trans('settings.system_version') }}
    @@ -29,6 +30,7 @@
    @yield('card')
    + @yield('after-card')
    diff --git a/resources/views/settings/sort-rules/create.blade.php b/resources/views/settings/sort-rules/create.blade.php new file mode 100644 index 000000000..e1d5c7c46 --- /dev/null +++ b/resources/views/settings/sort-rules/create.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.simple') + +@section('body') + +
    + + @include('settings.parts.navbar', ['selected' => 'settings']) + +
    +

    {{ trans('settings.sort_rule_create') }}

    + +
    + {{ csrf_field() }} + @include('settings.sort-rules.parts.form', ['model' => null]) + +
    + {{ trans('common.cancel') }} + +
    +
    +
    +
    + +@stop diff --git a/resources/views/settings/sort-rules/edit.blade.php b/resources/views/settings/sort-rules/edit.blade.php new file mode 100644 index 000000000..8bf04701f --- /dev/null +++ b/resources/views/settings/sort-rules/edit.blade.php @@ -0,0 +1,54 @@ +@extends('layouts.simple') + +@section('body') + +
    + + @include('settings.parts.navbar', ['selected' => 'settings']) + +
    +

    {{ trans('settings.sort_rule_edit') }}

    + +
    + {{ method_field('PUT') }} + {{ csrf_field() }} + + @include('settings.sort-rules.parts.form', ['model' => $rule]) + +
    + {{ trans('common.cancel') }} + +
    +
    +
    + +
    +
    +
    +

    {{ trans('settings.sort_rule_delete') }}

    +

    {{ trans('settings.sort_rule_delete_desc') }}

    + @if($errors->has('delete')) + @foreach($errors->get('delete') as $error) +

    {{ $error }}

    + @endforeach + @endif +
    +
    +
    + {{ method_field('DELETE') }} + {{ csrf_field() }} + + @if($errors->has('delete')) + + @endif + +
    + +
    +
    +
    +
    +
    +
    + +@stop diff --git a/resources/views/settings/sort-rules/parts/form.blade.php b/resources/views/settings/sort-rules/parts/form.blade.php new file mode 100644 index 000000000..d6de947b6 --- /dev/null +++ b/resources/views/settings/sort-rules/parts/form.blade.php @@ -0,0 +1,56 @@ +
    +
    +
    + +

    {{ trans('settings.sort_rule_details_desc') }}

    +
    +
    +
    + + @include('form.text', ['name' => 'name']) +
    +
    +
    + +
    + +

    {{ trans('settings.sort_rule_operations_desc') }}

    + @include('form.errors', ['name' => 'sequence']) + + + + @php + $configuredOps = old('sequence') ? \BookStack\Sorting\SortRuleOperation::fromSequence(old('sequence')) : ($model?->getOperations() ?? []); + @endphp + +
    +
    + +
      +
    • {{ trans('settings.sort_rule_configured_operations_empty') }}
    • + + @foreach($configuredOps as $operation) + @include('settings.sort-rules.parts.operation', ['operation' => $operation]) + @endforeach +
    +
    + +
    + +
      +
    • {{ trans('settings.sort_rule_available_operations_empty') }}
    • + @foreach(\BookStack\Sorting\SortRuleOperation::allExcluding($configuredOps) as $operation) + @include('settings.sort-rules.parts.operation', ['operation' => $operation]) + @endforeach +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/resources/views/settings/sort-rules/parts/operation.blade.php b/resources/views/settings/sort-rules/parts/operation.blade.php new file mode 100644 index 000000000..3feb68a47 --- /dev/null +++ b/resources/views/settings/sort-rules/parts/operation.blade.php @@ -0,0 +1,15 @@ +
  • +
    @icon('grip')
    +
    {{ $operation->getLabel() }}
    +
    + + + + +
    +
  • \ No newline at end of file diff --git a/resources/views/settings/sort-rules/parts/sort-rule-list-item.blade.php b/resources/views/settings/sort-rules/parts/sort-rule-list-item.blade.php new file mode 100644 index 000000000..5236cb412 --- /dev/null +++ b/resources/views/settings/sort-rules/parts/sort-rule-list-item.blade.php @@ -0,0 +1,12 @@ +
    + +
    + {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $rule->getOperations())) }} +
    +
    + @icon('book'){{ $rule->books_count ?? 0 }} +
    +
    \ No newline at end of file diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index a75dd6ac1..7790ba5a4 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -38,7 +38,7 @@
      + 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 @@
        + class="scroll-box available-option-list"> @foreach ($books as $book) @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach diff --git a/routes/web.php b/routes/web.php index 5bb9622e7..818472583 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Commands/AssignSortRuleCommandTest.php b/tests/Commands/AssignSortRuleCommandTest.php new file mode 100644 index 000000000..5b308cd7c --- /dev/null +++ b/tests/Commands/AssignSortRuleCommandTest.php @@ -0,0 +1,112 @@ +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); + } +} diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index deeead099..e444d165f 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -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() diff --git a/tests/Entity/SortTest.php b/tests/Sorting/BookSortTest.php similarity index 51% rename from tests/Entity/SortTest.php rename to tests/Sorting/BookSortTest.php index 9a5a2fe17..c4217a4cc 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -1,239 +1,15 @@ 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(); diff --git a/tests/Sorting/MoveTest.php b/tests/Sorting/MoveTest.php new file mode 100644 index 000000000..edae1f3a3 --- /dev/null +++ b/tests/Sorting/MoveTest.php @@ -0,0 +1,221 @@ +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); + } +} diff --git a/tests/Sorting/SortRuleTest.php b/tests/Sorting/SortRuleTest.php new file mode 100644 index 000000000..0f6f43cee --- /dev/null +++ b/tests/Sorting/SortRuleTest.php @@ -0,0 +1,221 @@ +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, + ]); + } + } +}