mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-03-24 06:57:26 +08:00
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:
parent
bf8a84a8b1
commit
d28278bba6
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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
9
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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';
|
||||
|
@ -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(',');
|
||||
|
41
resources/js/components/sort-set-manager.ts
Normal file
41
resources/js/components/sort-set-manager.ts
Normal 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(',');
|
||||
}
|
||||
}
|
51
resources/js/services/dual-lists.ts
Normal file
51
resources/js/services/dual-lists.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
|
15
resources/views/settings/sort-sets/parts/operation.blade.php
Normal file
15
resources/views/settings/sort-sets/parts/operation.blade.php
Normal 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>
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user