feat(quick-261022-k9k): admin save shortcuts and Create and new on record forms
- app/hotkeys.ts: dependency-free data-hotkey core (Ctrl/Cmd+S, Ctrl/Cmd+Enter, Ctrl/Cmd+Shift+S, Esc, / and ?), innermost-modal root, capture-phase modifier listener, platform labels and the help-dialog state - Button hotkey prop (data-hotkey, aria-keyshortcuts); shortcuts installed by AppShell - FormView: save keys and tooltips, Create and new on create forms via remountView - phrasebook: form.create_and_new and the shortcuts group (en, pl)
This commit is contained in:
@@ -3,6 +3,7 @@ import { computed, watchEffect } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { documentTitleForRoute } from './app/pageTitle'
|
||||
import AppShell from './components/shell/AppShell.vue'
|
||||
import { remountRevision } from './state/useRemount'
|
||||
|
||||
const route = useRoute()
|
||||
const inShell = computed(() => route.meta.shell === true)
|
||||
@@ -14,10 +15,12 @@ watchEffect(() => {
|
||||
|
||||
<template>
|
||||
<!-- Keyed by path, not full path: a new controller or record remounts its
|
||||
view, while list query changes (search, sort, page) keep it. -->
|
||||
view, while list query changes (search, sort, page) keep it. The
|
||||
remount revision is part of the key, so a view can remount itself in
|
||||
place (Create and new). -->
|
||||
<AppShell v-if="inShell">
|
||||
<RouterView v-slot="{ Component, route: current }">
|
||||
<component :is="Component" :key="current.path" />
|
||||
<component :is="Component" :key="`${current.path}#${remountRevision}`" />
|
||||
</RouterView>
|
||||
</AppShell>
|
||||
<RouterView v-else />
|
||||
|
||||
263
admin/src/app/hotkeys.ts
Normal file
263
admin/src/app/hotkeys.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// Admin keyboard shortcuts, after WinterCMS's data-hotkey: a button carries
|
||||
// the shortcut in a data-hotkey attribute (space-separated tokens) and the
|
||||
// shortcut clicks that button, so it does exactly what the button does and
|
||||
// nothing while the button is disabled. Supported tokens: mod+s, mod+enter,
|
||||
// mod+shift+s (mod is Ctrl or Cmd, both accepted on every platform, as
|
||||
// WinterCMS binds ctrl+s and cmd+s), escape, / and ?. An input carrying
|
||||
// a token is focused instead of clicked (the list search).
|
||||
//
|
||||
// Where a shortcut looks: the innermost open modal (a dialog with
|
||||
// aria-modal, or an alert dialog), else the whole page, so a modal form
|
||||
// takes the save keys from the form underneath and a confirmation takes
|
||||
// them from both (hotkeyRoot). Popovers are dialogs without aria-modal and
|
||||
// are not roots.
|
||||
//
|
||||
// Modifier shortcuts fire everywhere, also while typing in an input, a
|
||||
// textarea, a code field or the rich editor. Their listener runs in the
|
||||
// window capture phase and stops the event when a target exists, so the
|
||||
// rich editor does not also insert a hard break (Mod-Enter) or toggle
|
||||
// strikethrough (Mod-Shift-s); without a target the event passes through and
|
||||
// those editor keys keep working. Ctrl+S never opens the browser's Save page
|
||||
// inside the shell. Plain keys (Esc, / and ?) never fire in an editable
|
||||
// control, after a component handled the key (defaultPrevented), or while any
|
||||
// dismissable layer (dialog, popover, dropdown, select, link popover) is
|
||||
// open: those close on Esc themselves without calling preventDefault, so the
|
||||
// DOM is checked for their data-dismissable-layer attribute instead.
|
||||
import { readonly, ref } from 'vue'
|
||||
import { t } from './i18n'
|
||||
|
||||
export type HotkeyCombo = 'mod+s' | 'mod+enter' | 'mod+shift+s' | 'escape' | '/' | '?'
|
||||
|
||||
export interface ShortcutEntry {
|
||||
combo: HotkeyCombo
|
||||
labelKey: string
|
||||
}
|
||||
|
||||
/** Every shortcut the admin knows, in help-dialog order. */
|
||||
export const SHORTCUTS: readonly ShortcutEntry[] = [
|
||||
{ combo: 'mod+s', labelKey: 'backend::lang.form.save' },
|
||||
{ combo: 'mod+enter', labelKey: 'backend::lang.form.save_and_close' },
|
||||
{ combo: 'mod+shift+s', labelKey: 'backend::lang.form.create_and_new' },
|
||||
{ combo: 'escape', labelKey: 'backend::lang.shortcuts.leave' },
|
||||
{ combo: '/', labelKey: 'backend::lang.shortcuts.search' },
|
||||
{ combo: '?', labelKey: 'backend::lang.shortcuts.help' },
|
||||
]
|
||||
|
||||
const MOD_COMBOS: ReadonlySet<HotkeyCombo> = new Set(['mod+s', 'mod+enter', 'mod+shift+s'])
|
||||
|
||||
/** True on macOS and iOS (the labels use the ⌘ glyphs there). */
|
||||
export function isMacPlatform(platform?: string): boolean {
|
||||
const value = platform ?? (typeof navigator === 'undefined' ? '' : navigator.platform || '')
|
||||
return /mac|iphone|ipad|ipod/i.test(value)
|
||||
}
|
||||
|
||||
/** The letter of a modified key; event.code covers non-Latin layouts. */
|
||||
function letterOf(event: KeyboardEvent): string {
|
||||
if (/^[a-z]$/i.test(event.key)) {
|
||||
return event.key.toLowerCase()
|
||||
}
|
||||
const match = /^Key([A-Z])$/.exec(event.code ?? '')
|
||||
return match ? match[1]!.toLowerCase() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The shortcut a keydown means, or null. Modifier combos need Ctrl or Cmd
|
||||
* and no Alt (AltGr arrives as Ctrl+Alt and types letters such as ś).
|
||||
*/
|
||||
export function comboOf(event: KeyboardEvent): HotkeyCombo | null {
|
||||
if (event.isComposing) {
|
||||
return null
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.altKey) {
|
||||
return null
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
return event.shiftKey ? null : 'mod+enter'
|
||||
}
|
||||
if (letterOf(event) === 's') {
|
||||
return event.shiftKey ? 'mod+shift+s' : 'mod+s'
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (event.altKey) {
|
||||
return null
|
||||
}
|
||||
// Shift is ignored for / and ?: keyboard layouts place them differently.
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
return 'escape'
|
||||
case '/':
|
||||
return '/'
|
||||
case '?':
|
||||
return '?'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the target is inside an input, textarea, select or contenteditable element. */
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof (target as Element).closest !== 'function') {
|
||||
return false
|
||||
}
|
||||
return (target as Element).closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null
|
||||
}
|
||||
|
||||
/** The keys of a shortcut as shown on screen, one chip each. */
|
||||
export function shortcutParts(combo: HotkeyCombo, mac: boolean = isMacPlatform()): string[] {
|
||||
switch (combo) {
|
||||
case 'mod+s':
|
||||
return mac ? ['⌘', 'S'] : ['Ctrl', 'S']
|
||||
case 'mod+enter':
|
||||
return mac ? ['⌘', '↩'] : ['Ctrl', 'Enter']
|
||||
case 'mod+shift+s':
|
||||
return mac ? ['⇧', '⌘', 'S'] : ['Ctrl', 'Shift', 'S']
|
||||
case 'escape':
|
||||
return ['Esc']
|
||||
default:
|
||||
return [combo]
|
||||
}
|
||||
}
|
||||
|
||||
/** The shortcut as one string: Ctrl+Shift+S, or ⇧⌘S on macOS. */
|
||||
export function shortcutLabel(combo: HotkeyCombo, mac: boolean = isMacPlatform()): string {
|
||||
return shortcutParts(combo, mac).join(mac ? '' : '+')
|
||||
}
|
||||
|
||||
/** A button tooltip naming its shortcut, for example "Zapisz (Ctrl+S)". */
|
||||
export function shortcutHint(label: string, combo: HotkeyCombo, mac: boolean = isMacPlatform()): string {
|
||||
return t('backend::lang.shortcuts.hint', { label, keys: shortcutLabel(combo, mac) })
|
||||
}
|
||||
|
||||
const ARIA: Record<HotkeyCombo, string> = {
|
||||
'mod+s': 'Control+S Meta+S',
|
||||
'mod+enter': 'Control+Enter Meta+Enter',
|
||||
'mod+shift+s': 'Control+Shift+S Meta+Shift+S',
|
||||
escape: 'Escape',
|
||||
'/': '/',
|
||||
'?': '?',
|
||||
}
|
||||
|
||||
/** The aria-keyshortcuts value of a data-hotkey token list (Control and Meta forms of each mod token). */
|
||||
export function ariaKeyshortcuts(hotkey: string): string {
|
||||
return hotkey
|
||||
.split(/\s+/)
|
||||
.filter((token): token is HotkeyCombo => token in ARIA)
|
||||
.map((token) => ARIA[token])
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** The innermost open modal (aria-modal dialog or alert dialog), else the document. */
|
||||
export function hotkeyRoot(doc: Document = document): ParentNode {
|
||||
const modals = Array.from(
|
||||
doc.querySelectorAll('[role="dialog"][aria-modal="true"], [role="alertdialog"]'),
|
||||
).filter((element) => element.getAttribute('data-state') !== 'closed')
|
||||
return modals.at(-1) ?? doc
|
||||
}
|
||||
|
||||
/** The first element in root whose data-hotkey lists the combo. */
|
||||
export function hotkeyTarget(combo: HotkeyCombo, root: ParentNode): HTMLElement | null {
|
||||
return root.querySelector<HTMLElement>(`[data-hotkey~="${combo}"]`)
|
||||
}
|
||||
|
||||
/** The shortcuts the current screen answers, plus ? itself. */
|
||||
export function availableShortcuts(doc: Document = document): ShortcutEntry[] {
|
||||
const root = hotkeyRoot(doc)
|
||||
return SHORTCUTS.filter((entry) => entry.combo === '?' || hotkeyTarget(entry.combo, root) !== null)
|
||||
}
|
||||
|
||||
const help = ref<readonly ShortcutEntry[] | null>(null)
|
||||
|
||||
/** The entries the help dialog shows, or null while it is closed. */
|
||||
export const shortcutHelp = readonly(help)
|
||||
|
||||
export function openShortcutHelp(): void {
|
||||
help.value = availableShortcuts()
|
||||
}
|
||||
|
||||
export function closeShortcutHelp(): void {
|
||||
help.value = null
|
||||
}
|
||||
|
||||
function isDisabled(element: HTMLElement): boolean {
|
||||
return (element as HTMLButtonElement).disabled === true || element.getAttribute('aria-disabled') === 'true'
|
||||
}
|
||||
|
||||
function onModifierKey(event: KeyboardEvent): void {
|
||||
const combo = comboOf(event)
|
||||
if (!combo || !MOD_COMBOS.has(combo)) {
|
||||
return
|
||||
}
|
||||
if (combo === 'mod+s') {
|
||||
event.preventDefault()
|
||||
}
|
||||
const doc = (event.target as Node | null)?.ownerDocument ?? document
|
||||
const target = hotkeyTarget(combo, hotkeyRoot(doc))
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.repeat || isDisabled(target)) {
|
||||
return
|
||||
}
|
||||
target.click()
|
||||
}
|
||||
|
||||
function onPlainKey(event: KeyboardEvent): void {
|
||||
const combo = comboOf(event)
|
||||
if (combo !== 'escape' && combo !== '/' && combo !== '?') {
|
||||
return
|
||||
}
|
||||
if (event.defaultPrevented || isEditableTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
const doc = (event.target as Node | null)?.ownerDocument ?? document
|
||||
if (doc.querySelector('[data-dismissable-layer]')) {
|
||||
return
|
||||
}
|
||||
if (combo === '?') {
|
||||
event.preventDefault()
|
||||
openShortcutHelp()
|
||||
return
|
||||
}
|
||||
const target = hotkeyTarget(combo, doc)
|
||||
if (!target || isDisabled(target)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||
target.focus()
|
||||
target.select()
|
||||
} else {
|
||||
target.click()
|
||||
}
|
||||
}
|
||||
|
||||
let installs = 0
|
||||
|
||||
/**
|
||||
* Adds the window listeners; returns an idempotent uninstall. Reference
|
||||
* counted: the listeners exist once however many times it is installed and
|
||||
* go when every install has been undone.
|
||||
*/
|
||||
export function installHotkeys(): () => void {
|
||||
if (installs === 0) {
|
||||
window.addEventListener('keydown', onModifierKey, true)
|
||||
window.addEventListener('keydown', onPlainKey)
|
||||
}
|
||||
installs++
|
||||
let done = false
|
||||
return () => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
done = true
|
||||
installs--
|
||||
if (installs === 0) {
|
||||
window.removeEventListener('keydown', onModifierKey, true)
|
||||
window.removeEventListener('keydown', onPlainKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,19 +13,27 @@ import { activeEntry } from '../../state/useNavigation'
|
||||
import { settingsEntry, settingsLinkFor, settingsSectionOf } from '../../state/useSettings'
|
||||
import { useSidebar } from '../../state/useSidebar'
|
||||
import { t } from '../../app/i18n'
|
||||
import { closeShortcutHelp, installHotkeys } from '../../app/hotkeys'
|
||||
|
||||
// Navigation shell (design: Navigation shell and Top header; D-06, D-10):
|
||||
// the plugin rail, the section panel (the active plugin's side menu or, on a
|
||||
// settings page, the settings panel of its category; hidden when the
|
||||
// sidebar is collapsed by the viewport or by the admin), and the 64px header
|
||||
// with breadcrumbs and the user menu. The toast queue is mounted here so
|
||||
// toasts survive navigation.
|
||||
// toasts survive navigation. The keyboard shortcuts (app/hotkeys.ts) are
|
||||
// installed here, so only signed-in shell screens answer them.
|
||||
const route = useRoute()
|
||||
const { collapsed, watchViewport } = useSidebar()
|
||||
|
||||
const stopViewport = watchViewport()
|
||||
onBeforeUnmount(stopViewport)
|
||||
|
||||
const uninstallHotkeys = installHotkeys()
|
||||
onBeforeUnmount(() => {
|
||||
uninstallHotkeys()
|
||||
closeShortcutHelp()
|
||||
})
|
||||
|
||||
// "View site" (WinterCMS parity) opens the public site in a new tab: the
|
||||
// admin page's own origin, without the admin path prefix.
|
||||
const siteURL = `${window.location.origin}/`
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue'
|
||||
import { RouterLink, type RouteLocationRaw } from 'vue-router'
|
||||
import { ariaKeyshortcuts } from '../../app/hotkeys'
|
||||
|
||||
// One button for the whole admin (design tokens): primary, outline, ghost and
|
||||
// danger (outline). With `to` it renders a router link styled the same way.
|
||||
// A hotkey prop marks the button for the admin's keyboard shortcuts
|
||||
// (data-hotkey tokens, see app/hotkeys.ts).
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'outline' | 'ghost' | 'danger'
|
||||
@@ -12,8 +15,9 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
to?: RouteLocationRaw
|
||||
hotkey?: string
|
||||
}>(),
|
||||
{ variant: 'outline', size: 'md', type: 'button', disabled: false, icon: undefined, to: undefined },
|
||||
{ variant: 'outline', size: 'md', type: 'button', disabled: false, icon: undefined, to: undefined, hotkey: undefined },
|
||||
)
|
||||
|
||||
const variants: Record<NonNullable<typeof props.variant>, string> = {
|
||||
@@ -23,6 +27,8 @@ const variants: Record<NonNullable<typeof props.variant>, string> = {
|
||||
danger: 'border border-danger bg-surface text-danger hover:bg-danger-soft',
|
||||
}
|
||||
|
||||
const keyshortcuts = computed(() => (props.hotkey ? ariaKeyshortcuts(props.hotkey) : undefined))
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex shrink-0 items-center justify-center gap-2 rounded-control px-4 font-semibold whitespace-nowrap no-underline transition-colors duration-150 ease-out disabled:cursor-not-allowed disabled:opacity-60',
|
||||
props.size === 'sm' ? 'h-[38px]' : 'h-button',
|
||||
@@ -31,11 +37,24 @@ const classes = computed(() => [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-if="to !== undefined" :to="to" :class="classes">
|
||||
<RouterLink
|
||||
v-if="to !== undefined"
|
||||
:to="to"
|
||||
:class="classes"
|
||||
:data-hotkey="hotkey"
|
||||
:aria-keyshortcuts="keyshortcuts"
|
||||
>
|
||||
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<button v-else :type="type" :disabled="disabled" :class="classes">
|
||||
<button
|
||||
v-else
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:class="classes"
|
||||
:data-hotkey="hotkey"
|
||||
:aria-keyshortcuts="keyshortcuts"
|
||||
>
|
||||
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
15
admin/src/state/useRemount.ts
Normal file
15
admin/src/state/useRemount.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Lets a view ask App to remount the current route's component in place:
|
||||
// App keys the routed view by path plus this revision, so bumping it gives
|
||||
// the view a fresh instance without a navigation (Create and new on a
|
||||
// create form opens a blank form at the same path).
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
const revision = ref(0)
|
||||
|
||||
/** Part of App's routed view key; bumped by remountView. */
|
||||
export const remountRevision = readonly(revision)
|
||||
|
||||
/** Remounts the current route's view. */
|
||||
export function remountView(): void {
|
||||
revision.value++
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { api } from '../api/client'
|
||||
import { parentFileRoutes } from '../api/files'
|
||||
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta, RelationOption } from '../api/types'
|
||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||
import { shortcutHint } from '../app/hotkeys'
|
||||
import { currentLocale, message, t } from '../app/i18n'
|
||||
import { loadControllerAssets } from '../app/pluginAssets'
|
||||
import { SESSION_HEADER, newSessionKey } from '../app/sessionKey'
|
||||
@@ -58,12 +59,15 @@ import Button from '../components/ui/Button.vue'
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../components/ui/confirm'
|
||||
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
|
||||
import { remountView } from '../state/useRemount'
|
||||
import { showToast } from '../state/useToasts'
|
||||
|
||||
// Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen
|
||||
// 4). The view is keyed by route path, so one instance serves one record or
|
||||
// one create. Tabs group fields by `tab`; a sticky footer holds delete,
|
||||
// cancel, save-and-close and save; a dirty form confirms before leaving.
|
||||
// cancel, save-and-close and save (a create form also has create-and-new);
|
||||
// a dirty form confirms before leaving. The save buttons carry the keyboard
|
||||
// shortcuts (Ctrl/Cmd+S, Ctrl/Cmd+Enter, Ctrl/Cmd+Shift+S; app/hotkeys.ts).
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -527,6 +531,18 @@ async function onSaveAndClose(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utwórz i nowy (WinterCMS create_and_new): saves, then shows a blank create
|
||||
* form of the same model in place, with a new session key; both redirects
|
||||
* are ignored.
|
||||
*/
|
||||
async function onSaveAndNew(): Promise<void> {
|
||||
const record = await save()
|
||||
if (record) {
|
||||
remountView()
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (recordId === null || busy.value || !schema.value) {
|
||||
return
|
||||
@@ -676,10 +692,35 @@ void load()
|
||||
</Button>
|
||||
<div class="ml-auto flex items-center gap-2.5">
|
||||
<Button variant="ghost" data-action="cancel" @click="onLeave">{{ t('backend::lang.form.cancel') }}</Button>
|
||||
<Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSaveAndClose">
|
||||
<Button
|
||||
v-if="mode === 'create'"
|
||||
variant="outline"
|
||||
data-action="save-new"
|
||||
hotkey="mod+shift+s"
|
||||
:title="shortcutHint(t('backend::lang.form.create_and_new'), 'mod+shift+s')"
|
||||
:disabled="busy || loading || !schema || activeUploads > 0"
|
||||
@click="onSaveAndNew"
|
||||
>
|
||||
{{ t('backend::lang.form.create_and_new') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-action="save-close"
|
||||
hotkey="mod+enter"
|
||||
:title="shortcutHint(t('backend::lang.form.save_and_close'), 'mod+enter')"
|
||||
:disabled="busy || loading || !schema || activeUploads > 0"
|
||||
@click="onSaveAndClose"
|
||||
>
|
||||
{{ t('backend::lang.form.save_and_close') }}
|
||||
</Button>
|
||||
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSave">
|
||||
<Button
|
||||
variant="primary"
|
||||
data-action="save"
|
||||
hotkey="mod+s"
|
||||
:title="shortcutHint(t('backend::lang.form.save'), 'mod+s')"
|
||||
:disabled="busy || loading || !schema || activeUploads > 0"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
21
admin/tests/fixtures/lang.json
vendored
21
admin/tests/fixtures/lang.json
vendored
@@ -246,6 +246,9 @@
|
||||
"backend::lang.form.create": {
|
||||
"other": "Utwórz"
|
||||
},
|
||||
"backend::lang.form.create_and_new": {
|
||||
"other": "Utwórz i nowy"
|
||||
},
|
||||
"backend::lang.form.delete": {
|
||||
"other": "Usuń"
|
||||
},
|
||||
@@ -845,6 +848,24 @@
|
||||
},
|
||||
"backend::lang.settings.title": {
|
||||
"other": "Ustawienia"
|
||||
},
|
||||
"backend::lang.shortcuts.description": {
|
||||
"other": "Skróty dostępne na tym ekranie."
|
||||
},
|
||||
"backend::lang.shortcuts.help": {
|
||||
"other": "Pokaż skróty klawiaturowe"
|
||||
},
|
||||
"backend::lang.shortcuts.hint": {
|
||||
"other": ":label (:keys)"
|
||||
},
|
||||
"backend::lang.shortcuts.leave": {
|
||||
"other": "Anuluj i wróć"
|
||||
},
|
||||
"backend::lang.shortcuts.search": {
|
||||
"other": "Przejdź do wyszukiwania"
|
||||
},
|
||||
"backend::lang.shortcuts.title": {
|
||||
"other": "Skróty klawiaturowe"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
68
admin/tests/smoke/shortcuts.smoke.test.ts
Normal file
68
admin/tests/smoke/shortcuts.smoke.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Keyboard shortcuts on the whole app (mounted with attachTo, since the
|
||||
// shortcut listener reads the document): the save keys on record forms.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { clearUser } from '../../src/state/useAuth'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { clearToasts } from '../../src/state/useToasts'
|
||||
import lang from '../fixtures/lang.json'
|
||||
import formSchema from '../fixtures/widgets.form-schema.json'
|
||||
import record from '../fixtures/widgets.record.json'
|
||||
import { API, mountApp, requestsTo, type Route } from '../helpers'
|
||||
|
||||
const RECORD = `${API}/acme/demo/widgets/1`
|
||||
|
||||
const formRoutes: Record<string, Route> = {
|
||||
[`GET ${API}/acme/demo/widgets/schema/form`]: { body: formSchema },
|
||||
[`GET ${RECORD}`]: { body: record },
|
||||
[`PUT ${RECORD}`]: { body: record },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
clearToasts()
|
||||
setBundle(lang.data, 'pl')
|
||||
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
// Registered after the cleanup above, so it runs first (hooks run as a
|
||||
// stack): the app unmounts, with its listeners and portals, before the body
|
||||
// is cleared.
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function press(target: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
describe('record form save shortcuts', () => {
|
||||
it('saves an update form on Ctrl+S and Cmd+S without the browser Save page', async () => {
|
||||
const { calls } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
const ctrl = press(document.body, 's', { ctrlKey: true })
|
||||
expect(ctrl.defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
|
||||
const cmd = press(document.body, 's', { metaKey: true })
|
||||
expect(cmd.defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the shortcut in the save button tooltip', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
const save = wrapper.get('[data-action="save"]')
|
||||
expect(save.attributes('title')).toBe('Zapisz (Ctrl+S)')
|
||||
expect(save.attributes('aria-keyshortcuts')).toBe('Control+S Meta+S')
|
||||
expect(wrapper.get('[data-action="save-close"]').attributes('title')).toBe('Zapisz i zamknij (Ctrl+Enter)')
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,8 @@ update:
|
||||
|
||||
`modelClass` must equal the controller's `pact.AdminController.ModelName`. The `~/plugins/<vendor>/<plugin>/` prefix points into the plugin's own embedded tree.
|
||||
|
||||
On a create form Save follows `redirect` and Save and close follows `redirectClose`; on an update form Save stays on the record. The create form also has Create and new, which saves the record and opens an empty create form of the same controller, ignoring both redirects. The buttons answer Ctrl+S (Cmd+S on macOS), Ctrl+Enter and, on the create form, Ctrl+Shift+S.
|
||||
|
||||
An optional `recordActions` key lists the record actions the form offers, by the names the controller registers through `pact.HasAdminRecordActions`. It needs a `preview` block, because record actions are offered on the [preview screen](#preview-screen). The show response of a record then carries `meta.actions`: the declared actions the administrator may run and that apply to the record in its current state. See [Record actions](admin-controllers.md#record-actions).
|
||||
|
||||
## fields.yaml
|
||||
|
||||
@@ -68,6 +68,7 @@ list:
|
||||
form:
|
||||
save: Save
|
||||
save_and_close: Save and close
|
||||
create_and_new: Create and new
|
||||
cancel: Cancel
|
||||
delete: Delete
|
||||
none: — none —
|
||||
@@ -139,6 +140,13 @@ settings:
|
||||
empty: No settings are available.
|
||||
saved: Settings saved
|
||||
back: Back to settings
|
||||
shortcuts:
|
||||
title: Keyboard shortcuts
|
||||
description: Shortcuts available on this screen.
|
||||
hint: ":label (:keys)"
|
||||
leave: Cancel and go back
|
||||
search: Go to search
|
||||
help: Show keyboard shortcuts
|
||||
extension:
|
||||
busy: Loading…
|
||||
widget_failed: This control could not be loaded. Refresh the page; if it happens again, check the plugin's assets.
|
||||
|
||||
@@ -74,6 +74,7 @@ list:
|
||||
form:
|
||||
save: Zapisz
|
||||
save_and_close: Zapisz i zamknij
|
||||
create_and_new: Utwórz i nowy
|
||||
cancel: Anuluj
|
||||
delete: Usuń
|
||||
none: — brak —
|
||||
@@ -149,6 +150,13 @@ settings:
|
||||
empty: Brak dostępnych ustawień.
|
||||
saved: Zapisano ustawienia
|
||||
back: Wróć do ustawień
|
||||
shortcuts:
|
||||
title: Skróty klawiaturowe
|
||||
description: Skróty dostępne na tym ekranie.
|
||||
hint: ":label (:keys)"
|
||||
leave: Anuluj i wróć
|
||||
search: Przejdź do wyszukiwania
|
||||
help: Pokaż skróty klawiaturowe
|
||||
extension:
|
||||
busy: Wczytywanie…
|
||||
widget_failed: Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.
|
||||
|
||||
Reference in New Issue
Block a user