From d28278bba63eaa13d7ab691379b4b741c1fb83e6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 4 Feb 2025 15:14:22 +0000 Subject: [PATCH] Sorting: Added sort set form manager UI JS Extracted much code to be shared with the shelf books management UI --- app/Sorting/SortSet.php | 12 ++-- ...SortSetOption.php => SortSetOperation.php} | 10 ++-- lang/en/settings.php | 2 + package-lock.json | 9 ++- package.json | 1 + resources/js/components/index.ts | 1 + resources/js/components/shelf-sort.js | 48 ++------------- resources/js/components/sort-set-manager.ts | 41 +++++++++++++ resources/js/services/dual-lists.ts | 51 ++++++++++++++++ resources/sass/_components.scss | 19 ++++-- .../settings/sort-sets/parts/form.blade.php | 58 ++++++------------- .../sort-sets/parts/operation.blade.php | 15 +++++ resources/views/shelves/parts/form.blade.php | 4 +- 13 files changed, 168 insertions(+), 103 deletions(-) rename app/Sorting/{SortSetOption.php => SortSetOperation.php} (82%) create mode 100644 resources/js/components/sort-set-manager.ts create mode 100644 resources/js/services/dual-lists.ts create mode 100644 resources/views/settings/sort-sets/parts/operation.blade.php diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 42e1e0951..ee45c211f 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -15,21 +15,21 @@ use Illuminate\Database\Eloquent\Model; class SortSet extends Model { /** - * @return SortSetOption[] + * @return SortSetOperation[] */ - public function getOptions(): array + public function getOperations(): array { $strOptions = explode(',', $this->sequence); - $options = array_map(fn ($val) => SortSetOption::tryFrom($val), $strOptions); + $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); return array_filter($options); } /** - * @param SortSetOption[] $options + * @param SortSetOperation[] $options */ - public function setOptions(array $options): void + public function setOperations(array $options): void { - $values = array_map(fn (SortSetOption $opt) => $opt->value, $options); + $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } } diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOperation.php similarity index 82% rename from app/Sorting/SortSetOption.php rename to app/Sorting/SortSetOperation.php index bb878cf30..12fda669f 100644 --- a/app/Sorting/SortSetOption.php +++ b/app/Sorting/SortSetOperation.php @@ -2,7 +2,7 @@ namespace BookStack\Sorting; -enum SortSetOption: string +enum SortSetOperation: string { case NameAsc = 'name_asc'; case NameDesc = 'name_desc'; @@ -34,11 +34,11 @@ enum SortSetOption: string } /** - * @return SortSetOption[] + * @return SortSetOperation[] */ - public static function allExcluding(array $options): array + public static function allExcluding(array $operations): array { - $all = SortSetOption::cases(); - return array_diff($all, $options); + $all = SortSetOperation::cases(); + return array_diff($all, $operations); } } diff --git a/lang/en/settings.php b/lang/en/settings.php index b29ec2533..8bb2f6ef4 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -87,7 +87,9 @@ return [ 'sort_set_operations' => 'Sort Operations', 'sort_set_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_set_available_operations' => 'Available Operations', + 'sort_set_available_operations_empty' => 'No operations remaining', 'sort_set_configured_operations' => 'Configured Operations', + 'sort_set_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', 'sort_set_op_asc' => '(Asc)', 'sort_set_op_desc' => '(Desc)', 'sort_set_op_name' => 'Name - Alphabetical', 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/js/components/index.ts b/resources/js/components/index.ts index 12c991a51..affa25fcf 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 {SortSetManager} from './sort-set-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-set-manager.ts b/resources/js/components/sort-set-manager.ts new file mode 100644 index 000000000..c35ad41fe --- /dev/null +++ b/resources/js/components/sort-set-manager.ts @@ -0,0 +1,41 @@ +import {Component} from "./component.js"; +import Sortable from "sortablejs"; +import {buildListActions, sortActionClickListener} from "../services/dual-lists"; + + +export class SortSetManager 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-set-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/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php index 6df04a721..3f2220947 100644 --- a/resources/views/settings/sort-sets/parts/form.blade.php +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -1,4 +1,3 @@ -
@@ -13,59 +12,36 @@
-
+

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

- +
- -
    {{ trans('settings.sort_set_configured_operations') }} +
      - @foreach(($model?->getOptions() ?? []) as $option) -
    • -
      @icon('grip')
      -
      {{ $option->getLabel() }}
      -
      - - - - -
      -
    • + class="scroll-box configured-option-list"> +
    • {{ trans('settings.sort_set_configured_operations_empty') }}
    • + @foreach(($model?->getOperations() ?? []) as $option) + @include('settings.sort-sets.parts.operation') @endforeach
- -
    {{ trans('settings.sort_set_available_operations') }} +
      - @foreach(\BookStack\Sorting\SortSetOption::allExcluding($model?->getOptions() ?? []) as $option) -
    • -
      @icon('grip')
      -
      {{ $option->getLabel() }}
      -
      - - - - -
      -
    • + class="scroll-box available-option-list"> +
    • {{ trans('settings.sort_set_available_operations_empty') }}
    • + @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($model?->getOperations() ?? []) as $operation) + @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach
diff --git a/resources/views/settings/sort-sets/parts/operation.blade.php b/resources/views/settings/sort-sets/parts/operation.blade.php new file mode 100644 index 000000000..3feb68a47 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/operation.blade.php @@ -0,0 +1,15 @@ +
  • +
    @icon('grip')
    +
    {{ $operation->getLabel() }}
    +
    + + + + +
    +
  • \ 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