mail-navigation-escape.test.tsx4.2 KBView on GitHub import { act, renderHook } from '@testing-library/react';
import { HotkeysProvider } from 'react-hotkeys-hook';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import { useMailNavigation } from '@/modules/threads/threadList/hooks/use-mail-navigation';
import React from 'react';
/**
* Escape on the inbox must unselect EVERYTHING — the keyboard selection, the bulk (checkbox)
* selection, and the ambient conversation pointer that feeds the chat's context badges.
* Clearing only the keyboard selection left checkboxes ticked and the conversation still
* riding into the chat, which read as "escape did nothing".
*/
const wrapper = ({ children }: { children: React.ReactNode }) => (
<HotkeysProvider initiallyActiveScopes={['global', 'navigation']}>{children}</HotkeysProvider>
);
const pressEscape = () =>
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key=[redacted], code: 'Escape', bubbles: true }),
);
});
const renderNav = () =>
renderHook(
() => useMailNavigation({ items: [{ id: 'thread-1' }], containerRef: { current: null } }),
{ wrapper },
);
describe('mail list — Escape clears the whole selection', () => {
beforeEach(() => {
useCedarStore.setState((s) => ({
...s,
isThreadOpen: false,
isConversationOpen: false,
newEmail: null,
selectedThreadId: 'thread-1',
focusedIndex: 0,
bulkSelected: ['thread-1', 'thread-2'],
conversationSelection: ['conv-1', 'conv-2'],
activeConversationId: 'conv-1',
currentThreadList: [{ id: 'thread-1' }] as never[],
}));
});
it('clears the keyboard selection, the bulk selection and the ambient conversation', () => {
renderNav();
pressEscape();
const s = useCedarStore.getState();
expect(s.selectedThreadId).toBeNull();
expect(s.focusedIndex).toBeNull();
expect(s.bulkSelected).toEqual([]);
expect(s.conversationSelection).toEqual([]);
expect(s.activeConversationId).toBeNull();
});
it('leaves the selection alone while a thread is open (that view owns Escape)', () => {
useCedarStore.setState((s) => ({ ...s, isThreadOpen: true }));
renderNav();
pressEscape();
const s = useCedarStore.getState();
expect(s.selectedThreadId).toBe('thread-1');
expect(s.bulkSelected).toEqual(['thread-1', 'thread-2']);
});
});
/**
* Escape is also how you get out of a filtered inbox. It runs second, though: a single stage that
* cleared both would throw away a filter set on every ordinary deselect.
*/
describe('mail list — Escape clears the filters once nothing is selected', () => {
const FILTER = { id: 'f-1', type: 'from', value: '<email>', label: 'From: Chris' };
const setUp = (selection: Partial<ReturnType<typeof useCedarStore.getState>>) =>
useCedarStore.setState((s) => ({
...s,
isThreadOpen: false,
isConversationOpen: false,
newEmail: null,
selectedThreadId: null,
focusedIndex: null,
bulkSelected: [],
conversationSelection: [],
activeConversationId: null,
currentThreadList: [{ id: 'thread-1' }] as never[],
activeFilters: [FILTER] as never[],
value: 'renewal',
...selection,
}));
it('keeps the filters on the press that clears a selection', () => {
setUp({ selectedThreadId: 'thread-1', bulkSelected: ['thread-1'] });
renderNav();
pressEscape();
const s = useCedarStore.getState();
expect(s.selectedThreadId).toBeNull();
expect(s.bulkSelected).toEqual([]);
// The whole point of the two stages — the filter survives the deselect.
expect(s.activeFilters).toHaveLength(1);
expect(s.value).toBe('renewal');
});
it('clears the filter chips and the search box when nothing is selected', () => {
setUp({});
renderNav();
pressEscape();
const s = useCedarStore.getState();
expect(s.activeFilters).toEqual([]);
expect(s.value).toBe('');
});
it('takes two presses to get from a filtered selection to an unfiltered list', () => {
setUp({ selectedThreadId: 'thread-1', focusedIndex: 0 });
renderNav();
pressEscape();
expect(useCedarStore.getState().activeFilters).toHaveLength(1);
pressEscape();
expect(useCedarStore.getState().activeFilters).toEqual([]);
});
});