Merge pull request #3830 from BookStackApp/shortcuts

User interface shortcuts system
This commit is contained in:
Dan Brown 2022-11-10 10:32:56 +00:00 committed by GitHub
commit d520d6cab8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 660 additions and 115 deletions

View File

@ -158,6 +158,9 @@ class UserRepo
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
// Delete related activities
setting()->deleteUserSettings($user->id);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {

View File

@ -26,6 +26,8 @@ return [
// User-level default settings
'user' => [
'ui-shortcuts' => '{}',
'ui-shortcuts-enabled' => false,
'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),

View File

@ -3,6 +3,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Auth\UserRepo;
use BookStack\Settings\UserShortcutMap;
use Illuminate\Http\Request;
class UserPreferencesController extends Controller
@ -15,70 +16,76 @@ class UserPreferencesController extends Controller
}
/**
* Update the user's preferred book-list display setting.
* Show the user-specific interface shortcuts.
*/
public function switchBooksView(Request $request, int $id)
public function showShortcuts()
{
return $this->switchViewType($id, $request, 'books');
$shortcuts = UserShortcutMap::fromUserPreferences();
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
return view('users.preferences.shortcuts', [
'shortcuts' => $shortcuts,
'enabled' => $enabled,
]);
}
/**
* Update the user's preferred shelf-list display setting.
* Update the user-specific interface shortcuts.
*/
public function switchShelvesView(Request $request, int $id)
public function updateShortcuts(Request $request)
{
return $this->switchViewType($id, $request, 'bookshelves');
$enabled = $request->get('enabled') === 'true';
$providedShortcuts = $request->get('shortcut', []);
$shortcuts = new UserShortcutMap($providedShortcuts);
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
$this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
return redirect('/preferences/shortcuts');
}
/**
* Update the user's preferred shelf-view book list display setting.
* Update the preferred view format for a list view of the given type.
*/
public function switchShelfView(Request $request, int $id)
public function changeView(Request $request, string $type)
{
return $this->switchViewType($id, $request, 'bookshelf');
}
/**
* For a type of list, switch with stored view type for a user.
*/
protected function switchViewType(int $userId, Request $request, string $listName)
{
$this->checkPermissionOrCurrentUser('users-manage', $userId);
$viewType = $request->get('view_type');
if (!in_array($viewType, ['grid', 'list'])) {
$viewType = 'list';
$valueViewTypes = ['books', 'bookshelves', 'bookshelf'];
if (!in_array($type, $valueViewTypes)) {
return redirect()->back(500);
}
$user = $this->userRepo->getById($userId);
$key = $listName . '_view_type';
setting()->putUser($user, $key, $viewType);
$view = $request->get('view');
if (!in_array($view, ['grid', 'list'])) {
$view = 'list';
}
return redirect()->back(302, [], "/settings/users/$userId");
$key = $type . '_view_type';
setting()->putForCurrentUser($key, $view);
return redirect()->back(302, [], "/");
}
/**
* Change the stored sort type for a particular view.
*/
public function changeSort(Request $request, string $id, string $type)
public function changeSort(Request $request, string $type)
{
$validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags', 'page_revisions'];
if (!in_array($type, $validSortTypes)) {
return redirect()->back(500);
}
$this->checkPermissionOrCurrentUser('users-manage', $id);
$sort = substr($request->get('sort') ?: 'name', 0, 50);
$order = $request->get('order') === 'desc' ? 'desc' : 'asc';
$user = $this->userRepo->getById($id);
$sortKey = $type . '_sort';
$orderKey = $type . '_sort_order';
setting()->putUser($user, $sortKey, $sort);
setting()->putUser($user, $orderKey, $order);
setting()->putForCurrentUser($sortKey, $sort);
setting()->putForCurrentUser($orderKey, $order);
return redirect()->back(302, [], "/settings/users/{$id}");
return redirect()->back(302, [], "/");
}
/**
@ -87,7 +94,7 @@ class UserPreferencesController extends Controller
public function toggleDarkMode()
{
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true');
setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
return redirect()->back();
}
@ -95,18 +102,15 @@ class UserPreferencesController extends Controller
/**
* Update the stored section expansion preference for the given user.
*/
public function updateExpansionPreference(Request $request, string $id, string $key)
public function changeExpansion(Request $request, string $type)
{
$this->checkPermissionOrCurrentUser('users-manage', $id);
$keyWhitelist = ['home-details'];
if (!in_array($key, $keyWhitelist)) {
$typeWhitelist = ['home-details'];
if (!in_array($type, $typeWhitelist)) {
return response('Invalid key', 500);
}
$newState = $request->get('expand', 'false');
$user = $this->userRepo->getById($id);
setting()->putUser($user, 'section_expansion#' . $key, $newState);
setting()->putForCurrentUser('section_expansion#' . $type, $newState);
return response('', 204);
}
@ -129,6 +133,6 @@ class UserPreferencesController extends Controller
array_splice($currentFavorites, $index, 1);
}
setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites));
setting()->putForCurrentUser('code-language-favourites', implode(',', $currentFavorites));
}
}

View File

@ -194,6 +194,8 @@ class SettingService
/**
* Put a user-specific setting into the database.
* Can only take string value types since this may use
* the session which is less flexible to data types.
*/
public function putUser(User $user, string $key, string $value): bool
{
@ -206,6 +208,16 @@ class SettingService
return $this->put($this->userKey($user->id, $key), $value);
}
/**
* Put a user-specific setting into the database for the current access user.
* Can only take string value types since this may use
* the session which is less flexible to data types.
*/
public function putForCurrentUser(string $key, string $value)
{
return $this->putUser(user(), $key, $value);
}
/**
* Convert a setting key into a user-specific key.
*/

View File

@ -0,0 +1,82 @@
<?php
namespace BookStack\Settings;
class UserShortcutMap
{
protected const DEFAULTS = [
// Header actions
"home_view" => "1",
"shelves_view" => "2",
"books_view" => "3",
"settings_view" => "4",
"favourites_view" => "5",
"profile_view" => "6",
"global_search" => "/",
"logout" => "0",
// Common actions
"edit" => "e",
"new" => "n",
"copy" => "c",
"delete" => "d",
"favourite" => "f",
"export" => "x",
"sort" => "s",
"permissions" => "p",
"move" => "m",
"revisions" => "r",
// Navigation
"next" => "ArrowRight",
"previous" => "ArrowLeft",
];
/**
* @var array<string, string>
*/
protected array $mapping;
public function __construct(array $map)
{
$this->mapping = static::DEFAULTS;
$this->merge($map);
}
/**
* Merge the given map into the current shortcut mapping.
*/
protected function merge(array $map): void
{
foreach ($map as $key => $value) {
if (is_string($value) && isset($this->mapping[$key])) {
$this->mapping[$key] = $value;
}
}
}
/**
* Get the shortcut defined for the given ID.
*/
public function getShortcut(string $id): string
{
return $this->mapping[$id] ?? '';
}
/**
* Convert this mapping to JSON.
*/
public function toJson(): string
{
return json_encode($this->mapping);
}
/**
* Create a new instance from the current user's preferences.
*/
public static function fromUserPreferences(): self
{
$userKeyMap = setting()->getForCurrentUser('ui-shortcuts');
return new self(json_decode($userKeyMap, true) ?: []);
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm8 7H9c-.55 0-1-.45-1-1s.45-1 1-1h6c.55 0 1 .45 1 1s-.45 1-1 1zm1-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/></svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@ -73,7 +73,7 @@ class CodeEditor {
isFavorite ? this.favourites.add(language) : this.favourites.delete(language);
button.setAttribute('data-favourite', isFavorite ? 'true' : 'false');
window.$http.patch('/settings/users/update-code-language-favourite', {
window.$http.patch('/preferences/update-code-language-favourite', {
language: language,
active: isFavorite
});

View File

@ -43,6 +43,8 @@ import popup from "./popup.js"
import settingAppColorPicker from "./setting-app-color-picker.js"
import settingColorPicker from "./setting-color-picker.js"
import shelfSort from "./shelf-sort.js"
import shortcuts from "./shortcuts";
import shortcutInput from "./shortcut-input";
import sidebar from "./sidebar.js"
import sortableList from "./sortable-list.js"
import submitOnChange from "./submit-on-change.js"
@ -101,6 +103,8 @@ const componentMapping = {
"setting-app-color-picker": settingAppColorPicker,
"setting-color-picker": settingColorPicker,
"shelf-sort": shelfSort,
"shortcuts": shortcuts,
"shortcut-input": shortcutInput,
"sidebar": sidebar,
"sortable-list": sortableList,
"submit-on-change": submitOnChange,

View File

@ -0,0 +1,57 @@
/**
* Keys to ignore when recording shortcuts.
* @type {string[]}
*/
const ignoreKeys = ['Control', 'Alt', 'Shift', 'Meta', 'Super', ' ', '+', 'Tab', 'Escape'];
/**
* @extends {Component}
*/
class ShortcutInput {
setup() {
this.input = this.$el;
this.setupListeners();
}
setupListeners() {
this.listenerRecordKey = this.listenerRecordKey.bind(this);
this.input.addEventListener('focus', () => {
this.startListeningForInput();
});
this.input.addEventListener('blur', () => {
this.stopListeningForInput();
})
}
startListeningForInput() {
this.input.addEventListener('keydown', this.listenerRecordKey)
}
/**
* @param {KeyboardEvent} event
*/
listenerRecordKey(event) {
if (ignoreKeys.includes(event.key)) {
return;
}
const keys = [
event.ctrlKey ? 'Ctrl' : '',
event.metaKey ? 'Cmd' : '',
event.key,
];
this.input.value = keys.filter(s => Boolean(s)).join(' + ');
}
stopListeningForInput() {
this.input.removeEventListener('keydown', this.listenerRecordKey);
}
}
export default ShortcutInput;

View File

@ -0,0 +1,164 @@
function reverseMap(map) {
const reversed = {};
for (const [key, value] of Object.entries(map)) {
reversed[value] = key;
}
return reversed;
}
/**
* @extends {Component}
*/
class Shortcuts {
setup() {
this.container = this.$el;
this.mapById = JSON.parse(this.$opts.keyMap);
this.mapByShortcut = reverseMap(this.mapById);
this.hintsShowing = false;
this.hideHints = this.hideHints.bind(this);
this.setupListeners();
}
setupListeners() {
window.addEventListener('keydown', event => {
if (event.target.closest('input, select, textarea')) {
return;
}
this.handleShortcutPress(event);
});
window.addEventListener('keydown', event => {
if (event.key === '?') {
this.hintsShowing ? this.hideHints() : this.showHints();
}
});
}
/**
* @param {KeyboardEvent} event
*/
handleShortcutPress(event) {
const keys = [
event.ctrlKey ? 'Ctrl' : '',
event.metaKey ? 'Cmd' : '',
event.key,
];
const combo = keys.filter(s => Boolean(s)).join(' + ');
const shortcutId = this.mapByShortcut[combo];
if (shortcutId) {
const wasHandled = this.runShortcut(shortcutId);
if (wasHandled) {
event.preventDefault();
}
}
}
/**
* Run the given shortcut, and return a boolean to indicate if the event
* was successfully handled by a shortcut action.
* @param {String} id
* @return {boolean}
*/
runShortcut(id) {
const el = this.container.querySelector(`[data-shortcut="${id}"]`);
if (!el) {
return false;
}
if (el.matches('input, textarea, select')) {
el.focus();
return true;
}
if (el.matches('a, button')) {
el.click();
return true;
}
if (el.matches('div[tabindex]')) {
el.click();
el.focus();
return true;
}
console.error(`Shortcut attempted to be ran for element type that does not have handling setup`, el);
return false;
}
showHints() {
const wrapper = document.createElement('div');
wrapper.classList.add('shortcut-container');
this.container.append(wrapper);
const shortcutEls = this.container.querySelectorAll('[data-shortcut]');
const displayedIds = new Set();
for (const shortcutEl of shortcutEls) {
const id = shortcutEl.getAttribute('data-shortcut');
if (displayedIds.has(id)) {
continue;
}
const key = this.mapById[id];
this.showHintLabel(shortcutEl, key, wrapper);
displayedIds.add(id);
}
window.addEventListener('scroll', this.hideHints);
window.addEventListener('focus', this.hideHints);
window.addEventListener('blur', this.hideHints);
window.addEventListener('click', this.hideHints);
this.hintsShowing = true;
}
/**
* @param {Element} targetEl
* @param {String} key
* @param {Element} wrapper
*/
showHintLabel(targetEl, key, wrapper) {
const targetBounds = targetEl.getBoundingClientRect();
const label = document.createElement('div');
label.classList.add('shortcut-hint');
label.textContent = key;
const linkage = document.createElement('div');
linkage.classList.add('shortcut-linkage');
linkage.style.left = targetBounds.x + 'px';
linkage.style.top = targetBounds.y + 'px';
linkage.style.width = targetBounds.width + 'px';
linkage.style.height = targetBounds.height + 'px';
wrapper.append(label, linkage);
const labelBounds = label.getBoundingClientRect();
label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 6))}px`;
label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`;
}
hideHints() {
const wrapper = this.container.querySelector('.shortcut-container');
wrapper.remove();
window.removeEventListener('scroll', this.hideHints);
window.removeEventListener('focus', this.hideHints);
window.removeEventListener('blur', this.hideHints);
window.removeEventListener('click', this.hideHints);
this.hintsShowing = false;
}
}
export default Shortcuts;

View File

@ -25,6 +25,7 @@ return [
'actions' => 'Actions',
'view' => 'View',
'view_all' => 'View All',
'new' => 'New',
'create' => 'Create',
'update' => 'Update',
'edit' => 'Edit',
@ -80,12 +81,14 @@ return [
'none' => 'None',
// Header
'homepage' => 'Homepage',
'header_menu_expand' => 'Expand Header Menu',
'profile_menu' => 'Profile Menu',
'view_profile' => 'View Profile',
'edit_profile' => 'Edit Profile',
'dark_mode' => 'Dark Mode',
'light_mode' => 'Light Mode',
'global_search' => 'Global Search',
// Layout tabs
'tab_info' => 'Info',

View File

@ -0,0 +1,18 @@
<?php
/**
* Text used for user-preference specific views within bookstack.
*/
return [
'shortcuts' => 'Shortcuts',
'shortcuts_interface' => 'Interface Keyboard Shortcuts',
'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.',
'shortcuts_toggle_label' => 'Keyboard shortcuts enabled',
'shortcuts_section_navigation' => 'Navigation',
'shortcuts_section_actions' => 'Common Actions',
'shortcuts_save' => 'Save Shortcuts',
'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
'shortcuts_update_success' => 'Shortcut preferences have been updated!',
];

View File

@ -983,3 +983,31 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
.status-indicator-inactive {
background-color: $negative;
}
.shortcut-container {
background-color: rgba(0, 0, 0, 0.25);
pointer-events: none;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 99;
}
.shortcut-linkage {
position: fixed;
box-shadow: 0 0 4px 0 #FFF;
border-radius: 3px;
}
.shortcut-hint {
position: fixed;
padding: $-xxs $-xxs;
font-size: .85rem;
font-weight: 700;
line-height: 1;
background-color: #eee;
border-radius: 3px;
border: 1px solid #b4b4b4;
box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset;
color: #333;
}

View File

@ -474,3 +474,9 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
border-color: var(--color-primary);
outline: 1px solid var(--color-primary);
}
input.shortcut-input {
width: auto;
max-width: 120px;
height: auto;
}

View File

@ -37,7 +37,7 @@
<h5>{{ trans('common.actions') }}</h5>
<div class="icon-list text-primary">
@if(user()->can('book-create-all'))
<a href="{{ url("/create-book") }}" class="icon-list-item">
<a href="{{ url("/create-book") }}" data-shortcut="new" class="icon-list-item">
<span>@icon('add')</span>
<span>{{ trans('entities.books_create') }}</span>
</a>

View File

@ -94,13 +94,13 @@
<div class="icon-list text-primary">
@if(userCan('page-create', $book))
<a href="{{ $book->getUrl('/create-page') }}" class="icon-list-item">
<a href="{{ $book->getUrl('/create-page') }}" data-shortcut="new" class="icon-list-item">
<span>@icon('add')</span>
<span>{{ trans('entities.pages_new') }}</span>
</a>
@endif
@if(userCan('chapter-create', $book))
<a href="{{ $book->getUrl('/create-chapter') }}" class="icon-list-item">
<a href="{{ $book->getUrl('/create-chapter') }}" data-shortcut="new" class="icon-list-item">
<span>@icon('add')</span>
<span>{{ trans('entities.chapters_new') }}</span>
</a>
@ -109,29 +109,29 @@
<hr class="primary-background">
@if(userCan('book-update', $book))
<a href="{{ $book->getUrl('/edit') }}" class="icon-list-item">
<a href="{{ $book->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
<span>@icon('edit')</span>
<span>{{ trans('common.edit') }}</span>
</a>
<a href="{{ $book->getUrl('/sort') }}" class="icon-list-item">
<a href="{{ $book->getUrl('/sort') }}" data-shortcut="sort" class="icon-list-item">
<span>@icon('sort')</span>
<span>{{ trans('common.sort') }}</span>
</a>
@endif
@if(userCan('book-create-all'))
<a href="{{ $book->getUrl('/copy') }}" class="icon-list-item">
<a href="{{ $book->getUrl('/copy') }}" data-shortcut="copy" class="icon-list-item">
<span>@icon('copy')</span>
<span>{{ trans('common.copy') }}</span>
</a>
@endif
@if(userCan('restrictions-manage', $book))
<a href="{{ $book->getUrl('/permissions') }}" class="icon-list-item">
<a href="{{ $book->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
<span>@icon('lock')</span>
<span>{{ trans('entities.permissions') }}</span>
</a>
@endif
@if(userCan('book-delete', $book))
<a href="{{ $book->getUrl('/delete') }}" class="icon-list-item">
<a href="{{ $book->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
<span>@icon('delete')</span>
<span>{{ trans('common.delete') }}</span>
</a>

View File

@ -108,7 +108,7 @@
<div class="icon-list text-primary">
@if(userCan('page-create', $chapter))
<a href="{{ $chapter->getUrl('/create-page') }}" class="icon-list-item">
<a href="{{ $chapter->getUrl('/create-page') }}" data-shortcut="new" class="icon-list-item">
<span>@icon('add')</span>
<span>{{ trans('entities.pages_new') }}</span>
</a>
@ -117,31 +117,31 @@
<hr class="primary-background"/>
@if(userCan('chapter-update', $chapter))
<a href="{{ $chapter->getUrl('/edit') }}" class="icon-list-item">
<a href="{{ $chapter->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
<span>@icon('edit')</span>
<span>{{ trans('common.edit') }}</span>
</a>
@endif
@if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCan('chapter-create-all') || userCan('chapter-create-own'))
<a href="{{ $chapter->getUrl('/copy') }}" class="icon-list-item">
<a href="{{ $chapter->getUrl('/copy') }}" data-shortcut="copy" class="icon-list-item">
<span>@icon('copy')</span>
<span>{{ trans('common.copy') }}</span>
</a>
@endif
@if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter))
<a href="{{ $chapter->getUrl('/move') }}" class="icon-list-item">
<a href="{{ $chapter->getUrl('/move') }}" data-shortcut="move" class="icon-list-item">
<span>@icon('folder')</span>
<span>{{ trans('common.move') }}</span>
</a>
@endif
@if(userCan('restrictions-manage', $chapter))
<a href="{{ $chapter->getUrl('/permissions') }}" class="icon-list-item">
<a href="{{ $chapter->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
<span>@icon('lock')</span>
<span>{{ trans('entities.permissions') }}</span>
</a>
@endif
@if(userCan('chapter-delete', $chapter))
<a href="{{ $chapter->getUrl('/delete') }}" class="icon-list-item">
<a href="{{ $chapter->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
<span>@icon('delete')</span>
<span>{{ trans('common.delete') }}</span>
</a>
@ -149,7 +149,7 @@
@if($chapter->book && userCan('book-update', $chapter->book))
<hr class="primary-background"/>
<a href="{{ $chapter->book->getUrl('/sort') }}" class="icon-list-item">
<a href="{{ $chapter->book->getUrl('/sort') }}" data-shortcut="sort" class="icon-list-item">
<span>@icon('sort')</span>
<span>{{ trans('entities.chapter_sort_book') }}</span>
</a>

View File

@ -1,4 +1,4 @@
<form action="{{ url('/settings/users/toggle-dark-mode') }}" method="post">
<form action="{{ url('/preferences/toggle-dark-mode') }}" method="post">
{{ csrf_field() }}
{{ method_field('patch') }}
@if(setting()->getForCurrentUser('dark-mode-enabled'))

View File

@ -2,7 +2,7 @@
<div class="grid mx-l">
<div>
<a href="{{ url('/') }}" class="logo">
<a href="{{ url('/') }}" data-shortcut="home_view" class="logo">
@if(setting('app-logo', '') !== 'none')
<img class="logo-image" src="{{ setting('app-logo', '') === '' ? url('/logo.png') : url(setting('app-logo', '')) }}" alt="Logo">
@endif
@ -22,6 +22,7 @@
<form action="{{ url('/search') }}" method="GET" class="search-box" role="search">
<button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
<input id="header-search-box-input" type="text" name="term"
data-shortcut="global_search"
aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}"
value="{{ isset($searchTerm) ? $searchTerm : '' }}">
</form>
@ -33,14 +34,14 @@
@if (hasAppAccess())
<a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
@if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
<a href="{{ url('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
<a href="{{ url('/shelves') }}" data-shortcut="shelves_view">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
@endif
<a href="{{ url('/books') }}">@icon('books'){{ trans('entities.books') }}</a>
<a href="{{ url('/books') }}" data-shortcut="books_view">@icon('books'){{ trans('entities.books') }}</a>
@if(signedInUser() && userCan('settings-manage'))
<a href="{{ url('/settings') }}">@icon('settings'){{ trans('settings.settings') }}</a>
<a href="{{ url('/settings') }}" data-shortcut="settings_view">@icon('settings'){{ trans('settings.settings') }}</a>
@endif
@if(signedInUser() && userCan('users-manage') && !userCan('settings-manage'))
<a href="{{ url('/settings/users') }}">@icon('users'){{ trans('settings.users') }}</a>
<a href="{{ url('/settings/users') }}" data-shortcut="settings_view">@icon('users'){{ trans('settings.users') }}</a>
@endif
@endif
@ -61,13 +62,13 @@
</span>
<ul refs="dropdown@menu" class="dropdown-menu" role="menu">
<li>
<a href="{{ url('/favourites') }}" class="icon-item">
<a href="{{ url('/favourites') }}" data-shortcut="favourites_view" class="icon-item">
@icon('star')
<div>{{ trans('entities.my_favourites') }}</div>
</a>
</li>
<li>
<a href="{{ $currentUser->getProfileUrl() }}" class="icon-item">
<a href="{{ $currentUser->getProfileUrl() }}" data-shortcut="profile_view" class="icon-item">
@icon('user')
<div>{{ trans('common.view_profile') }}</div>
</a>
@ -82,13 +83,19 @@
<form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}"
method="post">
{{ csrf_field() }}
<button class="icon-item">
<button class="icon-item" data-shortcut="logout">
@icon('logout')
<div>{{ trans('auth.logout') }}</div>
</button>
</form>
</li>
<li><hr></li>
<li>
<a href="{{ url('/preferences/shortcuts') }}" class="icon-item">
@icon('shortcuts')
<div>{{ trans('preferences.shortcuts') }}</div>
</a>
</li>
<li>
@include('common.dark-mode-toggle', ['classes' => 'icon-item'])
</li>

View File

@ -9,7 +9,7 @@
action="{{ url()->current() }}"
method="get"
@else
action="{{ url("/settings/users/". user()->id ."/change-sort/{$type}") }}"
action="{{ url("/preferences/change-sort/{$type}") }}"
method="post"
@endif
>

View File

@ -2,8 +2,13 @@
class="dropdown-container"
id="export-menu">
<div refs="dropdown@toggle" class="icon-list-item"
aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('entities.export') }}" tabindex="0">
<div refs="dropdown@toggle"
class="icon-list-item"
aria-haspopup="true"
aria-expanded="false"
aria-label="{{ trans('entities.export') }}"
data-shortcut="export"
tabindex="0">
<span>@icon('export')</span>
<span>{{ trans('entities.export') }}</span>
</div>

View File

@ -5,7 +5,7 @@
{{ csrf_field() }}
<input type="hidden" name="type" value="{{ get_class($entity) }}">
<input type="hidden" name="id" value="{{ $entity->id }}">
<button type="submit" class="icon-list-item text-primary">
<button type="submit" data-shortcut="favourite" class="icon-list-item text-primary">
<span>@icon($isFavourite ? 'star' : 'star-outline')</span>
<span>{{ $isFavourite ? trans('common.unfavourite') : trans('common.favourite') }}</span>
</button>

View File

@ -1,7 +1,7 @@
<div id="sibling-navigation" class="grid half collapse-xs items-center mb-m px-m no-row-gap fade-in-when-active print-hidden">
<div>
@if($previous)
<a href="{{ $previous->getUrl() }}" class="outline-hover no-link-style block rounded">
<a href="{{ $previous->getUrl() }}" data-shortcut="previous" class="outline-hover no-link-style block rounded">
<div class="px-m pt-xs text-muted">{{ trans('common.previous') }}</div>
<div class="inline-block">
<div class="icon-list-item no-hover">
@ -14,7 +14,7 @@
</div>
<div>
@if($next)
<a href="{{ $next->getUrl() }}" class="outline-hover no-link-style block rounded text-xs-right">
<a href="{{ $next->getUrl() }}" data-shortcut="next" class="outline-hover no-link-style block rounded text-xs-right">
<div class="px-m pt-xs text-muted text-xs-right">{{ trans('common.next') }}</div>
<div class="inline block">
<div class="icon-list-item no-hover">

View File

@ -1,15 +1,15 @@
<div>
<form action="{{ url("/settings/users/". user()->id ."/switch-${type}-view") }}" method="POST" class="inline">
<form action="{{ url("/preferences/change-view/" . $type) }}" method="POST" class="inline">
{!! csrf_field() !!}
{!! method_field('PATCH') !!}
<input type="hidden" value="{{ $view === 'list'? 'grid' : 'list' }}" name="view_type">
@if ($view === 'list')
<button type="submit" class="icon-list-item text-primary">
<button type="submit" name="view" value="grid" class="icon-list-item text-primary">
<span class="icon">@icon('grid')</span>
<span>{{ trans('common.grid_view') }}</span>
</button>
@else
<button type="submit" class="icon-list-item text-primary">
<button type="submit" name="view" value="list" class="icon-list-item text-primary">
<span>@icon('list')</span>
<span>{{ trans('common.list_view') }}</span>
</button>

View File

@ -4,7 +4,7 @@ $key - Unique key for checking existing stored state.
--}}
<?php $isOpen = setting()->getForCurrentUser('section_expansion#'. $key); ?>
<button type="button" expand-toggle="{{ $target }}"
expand-toggle-update-endpoint="{{ url('/settings/users/'. user()->id .'/update-expansion-preference/' . $key) }}"
expand-toggle-update-endpoint="{{ url('/preferences/change-expansion/' . $key) }}"
expand-toggle-is-open="{{ $isOpen ? 'yes' : 'no' }}"
class="icon-list-item {{ $classes ?? '' }}">
<span>@icon('expand-text')</span>

View File

@ -18,7 +18,7 @@
<span>{{ trans('entities.shelves_new_action') }}</span>
</a>
@endif
@include('entities.view-toggle', ['view' => $view, 'type' => 'shelves'])
@include('entities.view-toggle', ['view' => $view, 'type' => 'bookshelves'])
@include('home.parts.expand-toggle', ['classes' => 'text-primary', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
@include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-primary'])
</div>

View File

@ -31,7 +31,12 @@
<!-- Translations for JS -->
@stack('translations')
</head>
<body class="@stack('body-class')">
<body
@if(setting()->getForCurrentUser('ui-shortcuts-enabled', false))
component="shortcuts"
option:shortcuts:key-map="{{ \BookStack\Settings\UserShortcutMap::fromUserPreferences()->toJson() }}"
@endif
class="@stack('body-class')">
@include('layouts.parts.base-body-start')
@include('common.skip-to-content')

View File

@ -145,37 +145,37 @@
{{--User Actions--}}
@if(userCan('page-update', $page))
<a href="{{ $page->getUrl('/edit') }}" class="icon-list-item">
<a href="{{ $page->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
<span>@icon('edit')</span>
<span>{{ trans('common.edit') }}</span>
</a>
@endif
@if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCanOnAny('create', \BookStack\Entities\Models\Chapter::class) || userCan('page-create-all') || userCan('page-create-own'))
<a href="{{ $page->getUrl('/copy') }}" class="icon-list-item">
<a href="{{ $page->getUrl('/copy') }}" data-shortcut="copy" class="icon-list-item">
<span>@icon('copy')</span>
<span>{{ trans('common.copy') }}</span>
</a>
@endif
@if(userCan('page-update', $page))
@if(userCan('page-delete', $page))
<a href="{{ $page->getUrl('/move') }}" class="icon-list-item">
<a href="{{ $page->getUrl('/move') }}" data-shortcut="move" class="icon-list-item">
<span>@icon('folder')</span>
<span>{{ trans('common.move') }}</span>
</a>
@endif
@endif
<a href="{{ $page->getUrl('/revisions') }}" class="icon-list-item">
<a href="{{ $page->getUrl('/revisions') }}" data-shortcut="revisions" class="icon-list-item">
<span>@icon('history')</span>
<span>{{ trans('entities.revisions') }}</span>
</a>
@if(userCan('restrictions-manage', $page))
<a href="{{ $page->getUrl('/permissions') }}" class="icon-list-item">
<a href="{{ $page->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
<span>@icon('lock')</span>
<span>{{ trans('entities.permissions') }}</span>
</a>
@endif
@if(userCan('page-delete', $page))
<a href="{{ $page->getUrl('/delete') }}" class="icon-list-item">
<a href="{{ $page->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
<span>@icon('delete')</span>
<span>{{ trans('common.delete') }}</span>
</a>

View File

@ -10,13 +10,13 @@
<h5>{{ trans('common.actions') }}</h5>
<div class="icon-list text-primary">
@if(userCan('bookshelf-create-all'))
<a href="{{ url("/create-shelf") }}" class="icon-list-item">
<a href="{{ url("/create-shelf") }}" data-shortcut="new" class="icon-list-item">
<span>@icon('add')</span>
<span>{{ trans('entities.shelves_new_action') }}</span>
</a>
@endif
@include('entities.view-toggle', ['view' => $view, 'type' => 'shelves'])
@include('entities.view-toggle', ['view' => $view, 'type' => 'bookshelves'])
<a href="{{ url('/tags') }}" class="icon-list-item">
<span>@icon('tag')</span>

View File

@ -112,32 +112,32 @@
<div class="icon-list text-primary">
@if(userCan('book-create-all') && userCan('bookshelf-update', $shelf))
<a href="{{ $shelf->getUrl('/create-book') }}" class="icon-list-item">
<a href="{{ $shelf->getUrl('/create-book') }}" data-shortcut="new" class="icon-list-item">
<span class="icon">@icon('add')</span>
<span>{{ trans('entities.books_new_action') }}</span>
</a>
@endif
@include('entities.view-toggle', ['view' => $view, 'type' => 'shelf'])
@include('entities.view-toggle', ['view' => $view, 'type' => 'bookshelf'])
<hr class="primary-background">
@if(userCan('bookshelf-update', $shelf))
<a href="{{ $shelf->getUrl('/edit') }}" class="icon-list-item">
<a href="{{ $shelf->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
<span>@icon('edit')</span>
<span>{{ trans('common.edit') }}</span>
</a>
@endif
@if(userCan('restrictions-manage', $shelf))
<a href="{{ $shelf->getUrl('/permissions') }}" class="icon-list-item">
<a href="{{ $shelf->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
<span>@icon('lock')</span>
<span>{{ trans('entities.permissions') }}</span>
</a>
@endif
@if(userCan('bookshelf-delete', $shelf))
<a href="{{ $shelf->getUrl('/delete') }}" class="icon-list-item">
<a href="{{ $shelf->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
<span>@icon('delete')</span>
<span>{{ trans('common.delete') }}</span>
</a>

View File

@ -0,0 +1,12 @@
<div class="flex-container-row justify-space-between items-center gap-m item-list-row">
<label for="shortcut-{{ $id }}" class="bold flex px-m py-xs">{{ $label }}</label>
<div class="px-m py-xs">
<input type="text"
component="shortcut-input"
class="small flex-none shortcut-input px-s py-xs"
id="shortcut-{{ $id }}"
name="shortcut[{{ $id }}]"
readonly
value="{{ $shortcuts->getShortcut($id) }}">
</div>
</div>

View File

@ -0,0 +1,75 @@
@extends('layouts.simple')
@section('body')
<div class="container small my-xl">
<section class="card content-wrap">
<form action="{{ url('/preferences/shortcuts') }}" method="post">
{{ method_field('put') }}
{{ csrf_field() }}
<h1 class="list-heading">{{ trans('preferences.shortcuts_interface') }}</h1>
<div class="flex-container-row items-center gap-m wrap mb-m">
<p class="flex mb-none min-width-m text-small text-muted">
{{ trans('preferences.shortcuts_toggle_desc') }}
{{ trans('preferences.shortcuts_customize_desc') }}
</p>
<div class="flex min-width-m text-m-center">
@include('form.toggle-switch', [
'name' => 'enabled',
'value' => $enabled,
'label' => trans('preferences.shortcuts_toggle_label'),
])
</div>
</div>
<hr>
<h2 class="list-heading mb-m">{{ trans('preferences.shortcuts_section_navigation') }}</h2>
<div class="flex-container-row wrap gap-m mb-xl">
<div class="flex min-width-l item-list">
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.homepage'), 'id' => 'home_view'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('entities.shelves'), 'id' => 'shelves_view'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('entities.books'), 'id' => 'books_view'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('settings.settings'), 'id' => 'settings_view'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('entities.my_favourites'), 'id' => 'favourites_view'])
</div>
<div class="flex min-width-l item-list">
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.view_profile'), 'id' => 'profile_view'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('auth.logout'), 'id' => 'logout'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.global_search'), 'id' => 'global_search'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.next'), 'id' => 'next'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.previous'), 'id' => 'previous'])
</div>
</div>
<h2 class="list-heading mb-m">{{ trans('preferences.shortcuts_section_actions') }}</h2>
<div class="flex-container-row wrap gap-m mb-xl">
<div class="flex min-width-l item-list">
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.new'), 'id' => 'new'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.edit'), 'id' => 'edit'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.copy'), 'id' => 'copy'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.delete'), 'id' => 'delete'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.favourite'), 'id' => 'favourite'])
</div>
<div class="flex min-width-l item-list">
@include('users.preferences.parts.shortcut-control', ['label' => trans('entities.export'), 'id' => 'export'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.sort'), 'id' => 'sort'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('entities.permissions'), 'id' => 'permissions'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('common.move'), 'id' => 'move'])
@include('users.preferences.parts.shortcut-control', ['label' => trans('entities.revisions'), 'id' => 'revisions'])
</div>
</div>
<p class="text-small text-muted">{{ trans('preferences.shortcuts_overlay_desc') }}</p>
<div class="form-group text-right">
<button class="button">{{ trans('preferences.shortcuts_save') }}</button>
</div>
</form>
</section>
</div>
@stop

View File

@ -246,13 +246,14 @@ Route::middleware('auth')->group(function () {
Route::delete('/settings/users/{id}', [UserController::class, 'destroy']);
// User Preferences
Route::patch('/settings/users/{id}/switch-books-view', [UserPreferencesController::class, 'switchBooksView']);
Route::patch('/settings/users/{id}/switch-shelves-view', [UserPreferencesController::class, 'switchShelvesView']);
Route::patch('/settings/users/{id}/switch-shelf-view', [UserPreferencesController::class, 'switchShelfView']);
Route::patch('/settings/users/{id}/change-sort/{type}', [UserPreferencesController::class, 'changeSort']);
Route::patch('/settings/users/{id}/update-expansion-preference/{key}', [UserPreferencesController::class, 'updateExpansionPreference']);
Route::patch('/settings/users/toggle-dark-mode', [UserPreferencesController::class, 'toggleDarkMode']);
Route::patch('/settings/users/update-code-language-favourite', [UserPreferencesController::class, 'updateCodeLanguageFavourite']);
Route::redirect('/preferences', '/');
Route::get('/preferences/shortcuts', [UserPreferencesController::class, 'showShortcuts']);
Route::put('/preferences/shortcuts', [UserPreferencesController::class, 'updateShortcuts']);
Route::patch('/preferences/change-view/{type}', [UserPreferencesController::class, 'changeView']);
Route::patch('/preferences/change-sort/{type}', [UserPreferencesController::class, 'changeSort']);
Route::patch('/preferences/change-expansion/{type}', [UserPreferencesController::class, 'changeExpansion']);
Route::patch('/preferences/toggle-dark-mode', [UserPreferencesController::class, 'toggleDarkMode']);
Route::patch('/preferences/update-code-language-favourite', [UserPreferencesController::class, 'updateCodeLanguageFavourite']);
// User API Tokens
Route::get('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'create']);

View File

@ -225,18 +225,18 @@ class BookTest extends TestCase
setting()->putUser($editor, 'books_view_type', 'list');
$resp = $this->actingAs($editor)->get('/books');
$this->withHtml($resp)->assertElementContains('form[action$="/settings/users/' . $editor->id . '/switch-books-view"]', 'Grid View');
$this->withHtml($resp)->assertElementExists('input[name="view_type"][value="grid"]');
$this->withHtml($resp)->assertElementContains('form[action$="/preferences/change-view/books"]', 'Grid View');
$this->withHtml($resp)->assertElementExists('button[name="view"][value="grid"]');
$resp = $this->patch("/settings/users/{$editor->id}/switch-books-view", ['view_type' => 'grid']);
$resp = $this->patch("/preferences/change-view/books", ['view' => 'grid']);
$resp->assertRedirect();
$this->assertEquals('grid', setting()->getUser($editor, 'books_view_type'));
$resp = $this->actingAs($editor)->get('/books');
$this->withHtml($resp)->assertElementContains('form[action$="/settings/users/' . $editor->id . '/switch-books-view"]', 'List View');
$this->withHtml($resp)->assertElementExists('input[name="view_type"][value="list"]');
$this->withHtml($resp)->assertElementContains('form[action$="/preferences/change-view/books"]', 'List View');
$this->withHtml($resp)->assertElementExists('button[name="view"][value="list"]');
$resp = $this->patch("/settings/users/{$editor->id}/switch-books-view", ['view_type' => 'list']);
$resp = $this->patch("/preferences/change-view/books", ['view_type' => 'list']);
$resp->assertRedirect();
$this->assertEquals('list', setting()->getUser($editor, 'books_view_type'));
}

View File

@ -160,6 +160,23 @@ class UserManagementTest extends TestCase
]);
}
public function test_delete_removes_user_preferences()
{
$editor = $this->getEditor();
setting()->putUser($editor, 'dark-mode-enabled', 'true');
$this->assertDatabaseHas('settings', [
'setting_key' => 'user:' . $editor->id . ':dark-mode-enabled',
'value' => 'true',
]);
$this->asAdmin()->delete("settings/users/{$editor->id}");
$this->assertDatabaseMissing('settings', [
'setting_key' => 'user:' . $editor->id . ':dark-mode-enabled',
]);
}
public function test_guest_profile_shows_limited_form()
{
$guest = User::getDefault();

View File

@ -6,12 +6,51 @@ use Tests\TestCase;
class UserPreferencesTest extends TestCase
{
public function test_interface_shortcuts_updating()
{
$this->asEditor();
// View preferences with defaults
$resp = $this->get('/preferences/shortcuts');
$resp->assertSee('Interface Keyboard Shortcuts');
$html = $this->withHtml($resp);
$html->assertFieldHasValue('enabled', 'false');
$html->assertFieldHasValue('shortcut[home_view]', '1');
// Update preferences
$resp = $this->put('/preferences/shortcuts', [
'enabled' => 'true',
'shortcut' => ['home_view' => 'Ctrl + 1'],
]);
$resp->assertRedirect('/preferences/shortcuts');
$resp->assertSessionHas('success', 'Shortcut preferences have been updated!');
// View updates to preferences page
$resp = $this->get('/preferences/shortcuts');
$html = $this->withHtml($resp);
$html->assertFieldHasValue('enabled', 'true');
$html->assertFieldHasValue('shortcut[home_view]', 'Ctrl + 1');
}
public function test_body_has_shortcuts_component_when_active()
{
$editor = $this->getEditor();
$this->actingAs($editor);
$this->withHtml($this->get('/'))->assertElementNotExists('body[component="shortcuts"]');
setting()->putUser($editor, 'ui-shortcuts-enabled', 'true');
$this->withHtml($this->get('/'))->assertElementExists('body[component="shortcuts"]');
}
public function test_update_sort_preference()
{
$editor = $this->getEditor();
$this->actingAs($editor);
$updateRequest = $this->patch('/settings/users/' . $editor->id . '/change-sort/books', [
$updateRequest = $this->patch('/preferences/change-sort/books', [
'sort' => 'created_at',
'order' => 'desc',
]);
@ -34,7 +73,7 @@ class UserPreferencesTest extends TestCase
$editor = $this->getEditor();
$this->actingAs($editor);
$updateRequest = $this->patch('/settings/users/' . $editor->id . '/change-sort/dogs', [
$updateRequest = $this->patch('/preferences/change-sort/dogs', [
'sort' => 'name',
'order' => 'asc',
]);
@ -49,7 +88,7 @@ class UserPreferencesTest extends TestCase
$editor = $this->getEditor();
$this->actingAs($editor);
$updateRequest = $this->patch('/settings/users/' . $editor->id . '/update-expansion-preference/home-details', ['expand' => 'true']);
$updateRequest = $this->patch('/preferences/change-expansion/home-details', ['expand' => 'true']);
$updateRequest->assertStatus(204);
$this->assertDatabaseHas('settings', [
@ -58,7 +97,7 @@ class UserPreferencesTest extends TestCase
]);
$this->assertEquals(true, setting()->getForCurrentUser('section_expansion#home-details'));
$invalidKeyRequest = $this->patch('/settings/users/' . $editor->id . '/update-expansion-preference/my-home-details', ['expand' => 'true']);
$invalidKeyRequest = $this->patch('/preferences/change-expansion/my-home-details', ['expand' => 'true']);
$invalidKeyRequest->assertStatus(500);
}
@ -69,7 +108,7 @@ class UserPreferencesTest extends TestCase
$this->withHtml($home)->assertElementNotExists('.dark-mode');
$this->assertEquals(false, setting()->getForCurrentUser('dark-mode-enabled', false));
$prefChange = $this->patch('/settings/users/toggle-dark-mode');
$prefChange = $this->patch('/preferences/toggle-dark-mode');
$prefChange->assertRedirect();
$this->assertEquals(true, setting()->getForCurrentUser('dark-mode-enabled'));
@ -123,7 +162,7 @@ class UserPreferencesTest extends TestCase
->assertElementNotExists('.featured-image-container')
->assertElementExists('.content-wrap .entity-list-item');
$req = $this->patch("/settings/users/{$editor->id}/switch-shelf-view", ['view_type' => 'grid']);
$req = $this->patch("/preferences/change-view/bookshelf", ['view' => 'grid']);
$req->assertRedirect($shelf->getUrl());
$resp = $this->actingAs($editor)->get($shelf->getUrl())
@ -140,14 +179,14 @@ class UserPreferencesTest extends TestCase
$page = $this->entities->page();
$this->actingAs($editor);
$this->patch('/settings/users/update-code-language-favourite', ['language' => 'php', 'active' => true]);
$this->patch('/settings/users/update-code-language-favourite', ['language' => 'javascript', 'active' => true]);
$this->patch('/preferences/update-code-language-favourite', ['language' => 'php', 'active' => true]);
$this->patch('/preferences/update-code-language-favourite', ['language' => 'javascript', 'active' => true]);
$resp = $this->get($page->getUrl('/edit'));
$resp->assertSee('option:code-editor:favourites="php,javascript"', false);
$this->patch('/settings/users/update-code-language-favourite', ['language' => 'ruby', 'active' => true]);
$this->patch('/settings/users/update-code-language-favourite', ['language' => 'php', 'active' => false]);
$this->patch('/preferences/update-code-language-favourite', ['language' => 'ruby', 'active' => true]);
$this->patch('/preferences/update-code-language-favourite', ['language' => 'php', 'active' => false]);
$resp = $this->get($page->getUrl('/edit'));
$resp->assertSee('option:code-editor:favourites="javascript,ruby"', false);