]> BookStack Code Mirror - bookstack/commitdiff
Sorting: Added sort set form manager UI JS
authorDan Brown <redacted>
Tue, 4 Feb 2025 15:14:22 +0000 (15:14 +0000)
committerDan Brown <redacted>
Tue, 4 Feb 2025 15:14:22 +0000 (15:14 +0000)
Extracted much code to be shared with the shelf books management UI

13 files changed:
app/Sorting/SortSet.php
app/Sorting/SortSetOperation.php [moved from app/Sorting/SortSetOption.php with 82% similarity]
lang/en/settings.php
package-lock.json
package.json
resources/js/components/index.ts
resources/js/components/shelf-sort.js
resources/js/components/sort-set-manager.ts [new file with mode: 0644]
resources/js/services/dual-lists.ts [new file with mode: 0644]
resources/sass/_components.scss
resources/views/settings/sort-sets/parts/form.blade.php
resources/views/settings/sort-sets/parts/operation.blade.php [new file with mode: 0644]
resources/views/shelves/parts/form.blade.php

index 42e1e0951c7903305bba0ee51ec2d6e90248c1e7..ee45c211f2dc15b7a68672ab7a5f4abfa8de4c3f 100644 (file)
@@ -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);
     }
 }
similarity index 82%
rename from app/Sorting/SortSetOption.php
rename to app/Sorting/SortSetOperation.php
index bb878cf30f961c012b4c9dbedcb71b4eca0f3dbd..12fda669fbd036e05bb0dd1ccd5eeed816dcb096 100644 (file)
@@ -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);
     }
 }
index b29ec25338b9a2ff8755263687dae6488db99e11..8bb2f6ef481725eee56e46730c97d3ad5e4908dd 100644 (file)
@@ -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',
index 1912106c2b7b9c92930214f03672418279cee684..44a735d2f0c3a736b6b556b04b73d3b65b240048 100644 (file)
@@ -4,7 +4,6 @@
   "requires": true,
   "packages": {
     "": {
-      "name": "bookstack",
       "dependencies": {
         "@codemirror/commands": "^6.7.1",
         "@codemirror/lang-css": "^6.3.1",
@@ -32,6 +31,7 @@
       },
       "devDependencies": {
         "@lezer/generator": "^1.7.2",
+        "@types/sortablejs": "^1.15.8",
         "chokidar-cli": "^3.0",
         "esbuild": "^0.24.0",
         "eslint": "^8.57.1",
         "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",
index 08af25d140f2406a6afbd831384ef0da22a473ef..4571ea77d76032705a17ede6d9437fae0e309e38 100644 (file)
@@ -20,6 +20,7 @@
   },
   "devDependencies": {
     "@lezer/generator": "^1.7.2",
+    "@types/sortablejs": "^1.15.8",
     "chokidar-cli": "^3.0",
     "esbuild": "^0.24.0",
     "eslint": "^8.57.1",
index 12c991a51d89b17c8397253ac2221bdd3cfc5328..affa25fcf31ddb96fc29f32e4d62453bc66ca1af 100644 (file)
@@ -50,6 +50,7 @@ export {ShelfSort} from './shelf-sort';
 export {Shortcuts} from './shortcuts';
 export {ShortcutInput} from './shortcut-input';
 export {SortableList} from './sortable-list';
+export {SortSetManager} from './sort-set-manager'
 export {SubmitOnChange} from './submit-on-change';
 export {Tabs} from './tabs';
 export {TagManager} from './tag-manager';
index 01ca11a333f10289f1fd185dbce66bc3a704316b..b56b01980a147cc17f9cda63bdff62b2ccdf47e4 100644 (file)
@@ -1,29 +1,6 @@
 import Sortable from 'sortablejs';
 import {Component} from './component';
-
-/**
- * @type {Object<string, function(HTMLElement, HTMLElement, HTMLElement)>}
- */
-const itemActions = {
-    move_up(item) {
-        const list = item.parentNode;
-        const index = Array.from(list.children).indexOf(item);
-        const newIndex = Math.max(index - 1, 0);
-        list.insertBefore(item, list.children[newIndex] || null);
-    },
-    move_down(item) {
-        const list = item.parentNode;
-        const index = Array.from(list.children).indexOf(item);
-        const newIndex = Math.min(index + 2, list.children.length);
-        list.insertBefore(item, list.children[newIndex] || null);
-    },
-    remove(item, shelfBooksList, allBooksList) {
-        allBooksList.appendChild(item);
-    },
-    add(item, shelfBooksList) {
-        shelfBooksList.appendChild(item);
-    },
-};
+import {buildListActions, sortActionClickListener} from '../services/dual-lists.ts';
 
 export class ShelfSort extends Component {
 
@@ -55,12 +32,9 @@ export class ShelfSort extends Component {
     }
 
     setupListeners() {
-        this.elem.addEventListener('click', event => {
-            const sortItemAction = event.target.closest('.scroll-box-item button[data-action]');
-            if (sortItemAction) {
-                this.sortItemActionClick(sortItemAction);
-            }
-        });
+        const listActions = buildListActions(this.allBookList, this.shelfBookList);
+        const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this));
+        this.elem.addEventListener('click', sortActionListener);
 
         this.bookSearchInput.addEventListener('input', () => {
             this.filterBooksByName(this.bookSearchInput.value);
@@ -93,20 +67,6 @@ export class ShelfSort extends Component {
         }
     }
 
-    /**
-     * Called when a sort item action button is clicked.
-     * @param {HTMLElement} sortItemAction
-     */
-    sortItemActionClick(sortItemAction) {
-        const sortItem = sortItemAction.closest('.scroll-box-item');
-        const {action} = sortItemAction.dataset;
-
-        const actionFunction = itemActions[action];
-        actionFunction(sortItem, this.shelfBookList, this.allBookList);
-
-        this.onChange();
-    }
-
     onChange() {
         const shelfBookElems = Array.from(this.shelfBookList.querySelectorAll('[data-id]'));
         this.input.value = shelfBookElems.map(elem => elem.getAttribute('data-id')).join(',');
diff --git a/resources/js/components/sort-set-manager.ts b/resources/js/components/sort-set-manager.ts
new file mode 100644 (file)
index 0000000..c35ad41
--- /dev/null
@@ -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 (file)
index 0000000..98f2af9
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Service for helping manage common dual-list scenarios.
+ * (Shelf book manager, sort set manager).
+ */
+
+type ListActionsSet = Record<string, ((item: HTMLElement) => void)>;
+
+export function buildListActions(
+    availableList: HTMLElement,
+    configuredList: HTMLElement,
+): ListActionsSet {
+    return {
+        move_up(item) {
+            const list = item.parentNode as HTMLElement;
+            const index = Array.from(list.children).indexOf(item);
+            const newIndex = Math.max(index - 1, 0);
+            list.insertBefore(item, list.children[newIndex] || null);
+        },
+        move_down(item) {
+            const list = item.parentNode as HTMLElement;
+            const index = Array.from(list.children).indexOf(item);
+            const newIndex = Math.min(index + 2, list.children.length);
+            list.insertBefore(item, list.children[newIndex] || null);
+        },
+        remove(item) {
+            availableList.appendChild(item);
+        },
+        add(item) {
+            configuredList.appendChild(item);
+        },
+    };
+}
+
+export function sortActionClickListener(actions: ListActionsSet, onChange: () => void) {
+    return (event: MouseEvent) => {
+        const sortItemAction = (event.target as Element).closest('.scroll-box-item button[data-action]') as HTMLElement|null;
+        if (sortItemAction) {
+            const sortItem = sortItemAction.closest('.scroll-box-item') as HTMLElement;
+            const action = sortItemAction.dataset.action;
+            if (!action) {
+                throw new Error('No action defined for clicked button');
+            }
+
+            const actionFunction = actions[action];
+            actionFunction(sortItem);
+
+            onChange();
+        }
+    };
+}
+
index 888b325275ea61890752f180c1c0ab370df1e799..58d39d3ee6e0e9c3f7e29d98bc17269fd6816ab7 100644 (file)
@@ -1062,12 +1062,16 @@ $btt-size: 40px;
     cursor: pointer;
     @include mixins.lightDark(background-color, #f8f8f8, #333);
   }
+  &.items-center {
+    align-items: center;
+  }
   .handle {
     color: #AAA;
     cursor: grab;
   }
   button {
     opacity: .6;
+    line-height: 1;
   }
   .handle svg {
     margin: 0;
@@ -1108,12 +1112,19 @@ input.scroll-box-search, .scroll-box-header-item {
   border-radius: 0 0 3px 3px;
 }
 
-.scroll-box[refs="shelf-sort@shelf-book-list"] [data-action="add"] {
+.scroll-box.configured-option-list [data-action="add"] {
   display: none;
 }
-.scroll-box[refs="shelf-sort@all-book-list"] [data-action="remove"],
-.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_up"],
-.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_down"],
+.scroll-box.available-option-list [data-action="remove"],
+.scroll-box.available-option-list [data-action="move_up"],
+.scroll-box.available-option-list [data-action="move_down"],
 {
   display: none;
+}
+
+.scroll-box > li.empty-state {
+  display: none;
+}
+.scroll-box > li.empty-state:last-child {
+  display: list-item;
 }
\ No newline at end of file
index 6df04a72191758879d4eaa04de299b4313207d75..3f22209471fee9576667d3ad84ade7491bbb4a81 100644 (file)
@@ -1,4 +1,3 @@
-
 <div class="setting-list">
     <div class="grid half">
         <div>
         </div>
     </div>
 
-    <div>
+    <div component="sort-set-manager">
         <label class="setting-list-label">{{ trans('settings.sort_set_operations') }}</label>
         <p class="text-muted text-small">{{ trans('settings.sort_set_operations_desc') }}</p>
 
-
+        <input refs="sort-set-manager@input" type="hidden" name="books"
+               value="{{ $model?->sequence ?? '' }}">
 
         <div class="grid half">
             <div class="form-group">
-                <label for="books" id="sort-set-configured-operations">{{ trans('settings.sort_set_configured_operations') }}</label>
-                <ul refs="sort-set@configured-operations-list"
+                <label for="books"
+                       id="sort-set-configured-operations">{{ trans('settings.sort_set_configured_operations') }}</label>
+                <ul refs="sort-set-manager@configured-operations-list"
                     aria-labelledby="sort-set-configured-operations"
-                    class="scroll-box">
-                    @foreach(($model?->getOptions() ?? []) as $option)
-                        <li data-id="{{ $option->value }}"
-                            class="scroll-box-item">
-                            <div class="handle px-s">@icon('grip')</div>
-                            <div>{{ $option->getLabel() }}</div>
-                            <div class="buttons flex-container-row items-center ml-auto px-xxs py-xs">
-                                <button type="button" data-action="move_up" class="icon-button p-xxs"
-                                        title="{{ trans('entities.books_sort_move_up') }}">@icon('chevron-up')</button>
-                                <button type="button" data-action="move_down" class="icon-button p-xxs"
-                                        title="{{ trans('entities.books_sort_move_down') }}">@icon('chevron-down')</button>
-                                <button type="button" data-action="remove" class="icon-button p-xxs"
-                                        title="{{ trans('common.remove') }}">@icon('remove')</button>
-                                <button type="button" data-action="add" class="icon-button p-xxs"
-                                        title="{{ trans('common.add') }}">@icon('add-small')</button>
-                            </div>
-                        </li>
+                    class="scroll-box configured-option-list">
+                    <li class="text-muted empty-state px-m py-s italic text-small">{{ trans('settings.sort_set_configured_operations_empty') }}</li>
+                    @foreach(($model?->getOperations() ?? []) as $option)
+                        @include('settings.sort-sets.parts.operation')
                     @endforeach
                 </ul>
             </div>
 
             <div class="form-group">
-                <label for="books" id="sort-set-available-operations">{{ trans('settings.sort_set_available_operations') }}</label>
-                <ul refs="sort-set@available-operations-list"
+                <label for="books"
+                       id="sort-set-available-operations">{{ trans('settings.sort_set_available_operations') }}</label>
+                <ul refs="sort-set-manager@available-operations-list"
                     aria-labelledby="sort-set-available-operations"
-                    class="scroll-box">
-                    @foreach(\BookStack\Sorting\SortSetOption::allExcluding($model?->getOptions() ?? []) as $option)
-                        <li data-id="{{ $option->value }}"
-                            class="scroll-box-item">
-                            <div class="handle px-s">@icon('grip')</div>
-                            <div>{{ $option->getLabel() }}</div>
-                            <div class="buttons flex-container-row items-center ml-auto px-xxs py-xs">
-                                <button type="button" data-action="move_up" class="icon-button p-xxs"
-                                        title="{{ trans('entities.books_sort_move_up') }}">@icon('chevron-up')</button>
-                                <button type="button" data-action="move_down" class="icon-button p-xxs"
-                                        title="{{ trans('entities.books_sort_move_down') }}">@icon('chevron-down')</button>
-                                <button type="button" data-action="remove" class="icon-button p-xxs"
-                                        title="{{ trans('common.remove') }}">@icon('remove')</button>
-                                <button type="button" data-action="add" class="icon-button p-xxs"
-                                        title="{{ trans('common.add') }}">@icon('add-small')</button>
-                            </div>
-                        </li>
+                    class="scroll-box available-option-list">
+                    <li class="text-muted empty-state px-m py-s italic text-small">{{ trans('settings.sort_set_available_operations_empty') }}</li>
+                    @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($model?->getOperations() ?? []) as $operation)
+                        @include('settings.sort-sets.parts.operation', ['operation' => $operation])
                     @endforeach
                 </ul>
             </div>
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 (file)
index 0000000..3feb68a
--- /dev/null
@@ -0,0 +1,15 @@
+<li data-id="{{ $operation->value }}"
+    class="scroll-box-item items-center">
+    <div class="handle px-s">@icon('grip')</div>
+    <div class="text-small">{{ $operation->getLabel() }}</div>
+    <div class="buttons flex-container-row items-center ml-auto px-xxs py-xxs">
+        <button type="button" data-action="move_up" class="icon-button p-xxs"
+                title="{{ trans('entities.books_sort_move_up') }}">@icon('chevron-up')</button>
+        <button type="button" data-action="move_down" class="icon-button p-xxs"
+                title="{{ trans('entities.books_sort_move_down') }}">@icon('chevron-down')</button>
+        <button type="button" data-action="remove" class="icon-button p-xxs"
+                title="{{ trans('common.remove') }}">@icon('remove')</button>
+        <button type="button" data-action="add" class="icon-button p-xxs"
+                title="{{ trans('common.add') }}">@icon('add-small')</button>
+    </div>
+</li>
\ No newline at end of file
index a75dd6ac1b53b62b3929d33d7d324a827810e966..7790ba5a4e7fab2e994999a8e17fbfaaf3c8e8f5 100644 (file)
@@ -38,7 +38,7 @@
         </div>
         <ul refs="shelf-sort@shelf-book-list"
             aria-labelledby="shelf-sort-books-label"
-            class="scroll-box">
+            class="scroll-box configured-option-list">
             @foreach (($shelf->visibleBooks ?? []) as $book)
                 @include('shelves.parts.shelf-sort-book-item', ['book' => $book])
             @endforeach
@@ -49,7 +49,7 @@
         <input type="text" refs="shelf-sort@book-search" class="scroll-box-search" placeholder="{{ trans('common.search') }}">
         <ul refs="shelf-sort@all-book-list"
             aria-labelledby="shelf-sort-all-books-label"
-            class="scroll-box">
+            class="scroll-box available-option-list">
             @foreach ($books as $book)
                 @include('shelves.parts.shelf-sort-book-item', ['book' => $book])
             @endforeach