Sorting: Added sort set form manager UI JS

Extracted much code to be shared with the shelf books management UI
This commit is contained in:
Dan Brown 2025-02-04 15:14:22 +00:00
parent bf8a84a8b1
commit d28278bba6
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
13 changed files with 168 additions and 103 deletions

View 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);
}
}

View 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);
}
}

View 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',

9
package-lock.json generated
View File

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

View File

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

View File

@ -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';

View File

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

View File

@ -0,0 +1,41 @@
import {Component} from "./component.js";
import Sortable from "sortablejs";
import {buildListActions, sortActionClickListener} from "../services/dual-lists";
export class 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(',');
}
}

View File

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

View File

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

View File

@ -1,4 +1,3 @@
<div class="setting-list">
<div class="grid half">
<div>
@ -13,59 +12,36 @@
</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>

View File

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

View File

@ -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