feat: Rewrite keyboardEventListener mixin to a composable (#9831)

This commit is contained in:
Sivin Varghese
2024-08-05 18:59:47 +05:30
committed by GitHub
parent b4b308336f
commit e0b67bb552
9 changed files with 474 additions and 120 deletions

View File

@@ -1,14 +1,15 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
@@ -41,7 +42,7 @@ export default {
IntersectionObserver,
VirtualList,
},
mixins: [conversationMixin, keyboardEventListenerMixins, filterMixin],
mixins: [conversationMixin, filterMixin],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
@@ -89,8 +90,63 @@ export default {
setup() {
const { uiSettings } = useUISettings();
const conversationListRef = ref(null);
const getKeyboardListenerParams = () => {
const allConversations = conversationListRef.value.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = conversationListRef.value.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
allConversations,
activeConversation,
activeConversationIndex,
lastConversationIndex,
};
};
const handlePreviousConversation = () => {
const { allConversations, activeConversationIndex } =
getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
};
const handleNextConversation = () => {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
};
const keyboardEvents = {
'Alt+KeyJ': {
action: () => handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleNextConversation(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, conversationListRef);
return {
uiSettings,
conversationListRef,
};
},
data() {
@@ -113,7 +169,7 @@ export default {
isContextMenuOpen: false,
appliedFilter: [],
infiniteLoaderOptions: {
root: this.$refs.conversationList,
root: this.$refs.conversationListRef,
rootMargin: '100px 0px 100px 0px',
},
@@ -490,58 +546,6 @@ export default {
}))
);
},
getKeyboardListenerParams() {
const allConversations = this.$refs.conversationList.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = this.$refs.conversationList.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
allConversations,
activeConversation,
activeConversationIndex,
lastConversationIndex,
};
},
handlePreviousConversation() {
const { allConversations, activeConversationIndex } =
this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
},
handleNextConversation() {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
},
getKeyboardEvents() {
return {
'Alt+KeyJ': {
action: () => this.handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => this.handleNextConversation(),
allowOnFocusedInput: true,
},
};
},
resetAndFetchData() {
this.appliedFilter = [];
this.resetBulkActions();
@@ -927,7 +931,7 @@ export default {
@assignTeam="onAssignTeamsForBulk"
/>
<div
ref="conversationList"
ref="conversationListRef"
class="flex-1 conversations-list"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>

View File

@@ -1,11 +1,13 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { getSidebarItems } from './config/default-sidebar';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useRoute, useRouter } from 'dashboard/composables/route';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import router, { routesWithPermissions } from '../../routes';
import { routesWithPermissions } from '../../routes';
import { hasPermissions } from '../../helper/permissionsHelper';
export default {
@@ -13,7 +15,6 @@ export default {
PrimarySidebar,
SecondarySidebar,
},
mixins: [keyboardEventListenerMixins],
props: {
showSecondarySidebar: {
type: Boolean,
@@ -24,6 +25,52 @@ export default {
default: '',
},
},
setup(props, { emit }) {
const sidebarRef = ref(null);
const route = useRoute();
const router = useRouter();
const toggleKeyShortcutModal = () => {
emit('openKeyShortcutModal');
};
const closeKeyShortcutModal = () => {
emit('closeKeyShortcutModal');
};
const isCurrentRouteSameAsNavigation = routeName => {
return route.name === routeName;
};
const navigateToRoute = routeName => {
if (!isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
};
const keyboardEvents = {
'$mod+Slash': {
action: toggleKeyShortcutModal,
},
'$mod+Escape': {
action: closeKeyShortcutModal,
},
'Alt+KeyC': {
action: () => navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => navigateToRoute('agent_list'),
},
};
useKeyboardEvents(keyboardEvents, sidebarRef);
return {
toggleKeyShortcutModal,
sidebarRef,
};
},
data() {
return {
showOptionsMenu: false,
@@ -131,38 +178,6 @@ export default {
this.$store.dispatch('customViews/get', this.activeCustomView);
}
},
toggleKeyShortcutModal() {
this.$emit('openKeyShortcutModal');
},
closeKeyShortcutModal() {
this.$emit('closeKeyShortcutModal');
},
getKeyboardEvents() {
return {
'$mod+Slash': this.toggleKeyShortcutModal,
'$mod+Escape': this.closeKeyShortcutModal,
'Alt+KeyC': {
action: () => this.navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => this.navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => this.navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => this.navigateToRoute('agent_list'),
},
};
},
navigateToRoute(routeName) {
if (!this.isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
},
isCurrentRouteSameAsNavigation(routeName) {
return this.$route.name === routeName;
},
toggleSupportChatWindow() {
window.$chatwoot.toggle();
},
@@ -180,7 +195,7 @@ export default {
</script>
<template>
<aside class="flex h-full">
<aside ref="sidebarRef" class="flex h-full">
<PrimarySidebar
:logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName"

View File

@@ -1,18 +1,14 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
name: 'ReplyTopPanel',
mixins: [keyboardEventListenerMixins],
props: {
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
setReplyMode: {
type: Function,
default: () => {},
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -26,6 +22,36 @@ export default {
default: false,
},
},
setup(props, { emit }) {
const replyTopRef = ref(null);
const setReplyMode = mode => {
emit('setReplyMode', mode);
};
const handleReplyClick = () => {
setReplyMode(REPLY_EDITOR_MODES.REPLY);
};
const handleNoteClick = () => {
setReplyMode(REPLY_EDITOR_MODES.NOTE);
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => handleReplyClick(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, replyTopRef);
return {
handleReplyClick,
handleNoteClick,
replyTopRef,
};
},
computed: {
replyButtonClass() {
return {
@@ -46,31 +72,14 @@ export default {
: `${this.charactersRemaining} ${CHAR_LENGTH_WARNING.UNDER_50}`;
},
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyP': {
action: () => this.handleNoteClick(),
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => this.handleReplyClick(),
allowOnFocusedInput: true,
},
};
},
handleReplyClick() {
this.setReplyMode(REPLY_EDITOR_MODES.REPLY);
},
handleNoteClick() {
this.setReplyMode(REPLY_EDITOR_MODES.NOTE);
},
},
};
</script>
<template>
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
<div
ref="replyTopRef"
class="flex justify-between bg-black-50 dark:bg-slate-800"
>
<div class="button-group">
<woot-button
variant="clear"

View File

@@ -1091,10 +1091,10 @@ export default {
/>
<ReplyTopPanel
:mode="replyType"
:set-reply-mode="setReplyMode"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popoutReplyBox"
@setReplyMode="setReplyMode"
@click="$emit('click')"
/>
<ArticleSearchPopover

View File

@@ -0,0 +1,101 @@
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import {
LAYOUT_QWERTY,
LAYOUT_QWERTZ,
LAYOUT_AZERTY,
} from 'shared/helpers/KeyboardHelpers';
describe('useDetectKeyboardLayout', () => {
beforeEach(() => {
window.cw_keyboard_layout = null;
});
it('returns cached layout if available', async () => {
window.cw_keyboard_layout = LAYOUT_QWERTY;
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
});
it('should detect QWERTY layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
});
it('should detect QWERTZ layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'z'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTZ);
});
it('should detect AZERTY layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'a'],
['KeyW', 'z'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_AZERTY);
});
it('should use legacy method if navigator.keyboard is not available', async () => {
navigator.keyboard = undefined;
const layout = await useDetectKeyboardLayout();
expect([LAYOUT_QWERTY, LAYOUT_QWERTZ, LAYOUT_AZERTY]).toContain(layout);
});
it('should cache the detected layout', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
const layoutAgain = await useDetectKeyboardLayout();
expect(layoutAgain).toBe(LAYOUT_QWERTY);
expect(navigator.keyboard.getLayoutMap).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,29 @@
import { unref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
describe('useKeyboardEvents', () => {
it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined();
});
it('should return a function', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function);
});
it('should set up listeners on mount and remove them on unmount', async () => {
const el = document.createElement('div');
const elRef = unref({ value: el });
const events = {
'ALT+KeyL': () => {},
};
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events, elRef);
mountedMock();
unmountedMock();
expect(mountedMock).toHaveBeenCalled();
expect(unmountedMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,73 @@
import {
LAYOUT_QWERTY,
LAYOUT_QWERTZ,
LAYOUT_AZERTY,
} from 'shared/helpers/KeyboardHelpers';
/**
* Detects the keyboard layout using a legacy method by creating a hidden input and dispatching a key event.
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
*/
async function detectLegacy() {
const input = document.createElement('input');
input.style.position = 'fixed';
input.style.top = '-100px';
document.body.appendChild(input);
input.focus();
return new Promise(resolve => {
const keyboardEvent = new KeyboardEvent('keypress', {
key: 'y',
keyCode: 89,
which: 89,
bubbles: true,
cancelable: true,
});
const handler = e => {
document.body.removeChild(input);
document.removeEventListener('keypress', handler);
if (e.key === 'z') {
resolve(LAYOUT_QWERTY);
} else if (e.key === 'y') {
resolve(LAYOUT_QWERTZ);
} else {
resolve(LAYOUT_AZERTY);
}
};
document.addEventListener('keypress', handler);
input.dispatchEvent(keyboardEvent);
});
}
/**
* Detects the keyboard layout using the modern navigator.keyboard API.
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
*/
async function detect() {
const map = await navigator.keyboard.getLayoutMap();
const q = map.get('KeyQ');
const w = map.get('KeyW');
const e = map.get('KeyE');
const r = map.get('KeyR');
const t = map.get('KeyT');
const y = map.get('KeyY');
return [q, w, e, r, t, y].join('').toUpperCase();
}
/**
* Uses either the modern or legacy method to detect the keyboard layout, caching the result.
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
*/
export async function useDetectKeyboardLayout() {
const cachedLayout = window.cw_keyboard_layout;
if (cachedLayout) {
return cachedLayout;
}
const layout = navigator.keyboard ? await detect() : await detectLegacy();
window.cw_keyboard_layout = layout;
return layout;
}

View File

@@ -0,0 +1,115 @@
import { onMounted, onBeforeUnmount, unref } from 'vue';
import {
isActiveElementTypeable,
isEscape,
keysToModifyInQWERTZ,
LAYOUT_QWERTZ,
} from 'shared/helpers/KeyboardHelpers';
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { createKeybindingsHandler } from 'tinykeys';
const keyboardListenerMap = new WeakMap();
/**
* Determines if the keyboard event should be ignored based on the element type and handler settings.
* @param {Event} e - The event object.
* @param {Object|Function} handler - The handler configuration or function.
* @returns {boolean} - True if the event should be ignored, false otherwise.
*/
const shouldIgnoreEvent = (e, handler) => {
const isTypeable = isActiveElementTypeable(e);
const allowOnFocusedInput =
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
if (isTypeable) {
if (isEscape(e)) {
e.target.blur();
}
return !allowOnFocusedInput;
}
return false;
};
/**
* Wraps the event handler to include custom logic before executing the handler.
* @param {Function} handler - The original event handler.
* @returns {Function} - The wrapped handler.
*/
const keydownWrapper = handler => {
return e => {
if (shouldIgnoreEvent(e, handler)) return;
// extract the action to perform from the handler
const actionToPerform =
typeof handler === 'function' ? handler : handler.action;
actionToPerform(e);
};
};
/**
* Wraps all provided keyboard events in handlers that respect the current keyboard layout.
* @param {Object} events - The object containing event names and their handlers.
* @returns {Object} - The object with event names possibly modified based on the keyboard layout and wrapped handlers.
*/
async function wrapEventsInKeybindingsHandler(events) {
const wrappedEvents = {};
const currentLayout = await useDetectKeyboardLayout();
Object.keys(events).forEach(originalEventName => {
const modifiedEventName =
currentLayout === LAYOUT_QWERTZ &&
keysToModifyInQWERTZ.has(originalEventName)
? `Shift+${originalEventName}`
: originalEventName;
wrappedEvents[modifiedEventName] = keydownWrapper(
events[originalEventName]
);
});
return wrappedEvents;
}
/**
* Sets up keyboard event listeners on the specified element.
* @param {Element} root - The DOM element to attach listeners to.
* @param {Object} events - The events to listen for.
*/
const setupListeners = (root, events) => {
if (root instanceof Element && events) {
const keydownHandler = createKeybindingsHandler(events);
const handler = window.addEventListener('keydown', keydownHandler);
keyboardListenerMap.set(root, handler);
}
};
/**
* Removes keyboard event listeners from the specified element.
* @param {Element} root - The DOM element to remove listeners from.
*/
const removeListeners = root => {
if (root instanceof Element) {
const handlerToRemove = keyboardListenerMap.get(root);
document.removeEventListener('keydown', handlerToRemove);
keyboardListenerMap.delete(root);
}
};
/**
* Vue composable to handle keyboard events with support for different keyboard layouts.
* @param {Object} keyboardEvents - The keyboard events to handle.
* @param {ref} elRef - A Vue ref to the element to attach the keyboard events to.
*/
export function useKeyboardEvents(keyboardEvents, elRef = null) {
onMounted(async () => {
const el = unref(elRef);
const getKeyboardEvents = () => keyboardEvents || null;
const events = getKeyboardEvents();
const wrappedEvents = await wrapEventsInKeybindingsHandler(events);
setupListeners(el, wrappedEvents);
});
onBeforeUnmount(() => {
const el = unref(elRef);
removeListeners(el);
});
}

View File

@@ -22,6 +22,14 @@ export const hasPressedCommandAndEnter = e => {
return hasPressedCommand(e) && isEnter(e);
};
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
// https://github.com/chatwoot/chatwoot/issues/9492
export const keysToModifyInQWERTZ = new Set(['Alt+KeyP', 'Alt+KeyL']);
export const LAYOUT_QWERTY = 'QWERTY';
export const LAYOUT_QWERTZ = 'QWERTZ';
export const LAYOUT_AZERTY = 'AZERTY';
/**
* Determines whether the active element is typeable.
*