perf(renderer): lazy mount task context menus
This commit is contained in:
parent
2c13516d9f
commit
60d806135c
2 changed files with 180 additions and 49 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { useAppTranslation } from '@features/localization/renderer';
|
||||
import {
|
||||
ContextMenu,
|
||||
|
|
@ -34,63 +36,66 @@ export const TaskContextMenu = ({
|
|||
children,
|
||||
}: TaskContextMenuProps): React.JSX.Element => {
|
||||
const { t } = useAppTranslation('common');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu open={open} onOpenChange={setOpen}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="w-full">{children}</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<ContextMenuItem onSelect={onTogglePin}>
|
||||
{isPinned ? (
|
||||
{open ? (
|
||||
<ContextMenuContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<ContextMenuItem onSelect={onTogglePin}>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<PinOff className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.unpin')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.pin')}</span>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onSelect={onRename}>
|
||||
<Pencil className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.rename')}</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onSelect={onMarkUnread}>
|
||||
<Mail className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.markUnread')}</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem onSelect={onToggleArchive}>
|
||||
{isArchived ? (
|
||||
<>
|
||||
<ArchiveRestore className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.unarchive')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.archive')}</span>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
|
||||
{onDelete && (
|
||||
<>
|
||||
<PinOff className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.unpin')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.pin')}</span>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={onDelete} className="text-red-400 focus:text-red-400">
|
||||
<Trash2 className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.deleteTask')}</span>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onSelect={onRename}>
|
||||
<Pencil className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.rename')}</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onSelect={onMarkUnread}>
|
||||
<Mail className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.markUnread')}</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem onSelect={onToggleArchive}>
|
||||
{isArchived ? (
|
||||
<>
|
||||
<ArchiveRestore className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.unarchive')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.archive')}</span>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
|
||||
{onDelete && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={onDelete} className="text-red-400 focus:text-red-400">
|
||||
<Trash2 className="size-3.5 shrink-0" />
|
||||
<span>{t('taskContextMenu.deleteTask')}</span>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenuContent>
|
||||
) : null}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
126
test/renderer/components/sidebar/TaskContextMenu.test.tsx
Normal file
126
test/renderer/components/sidebar/TaskContextMenu.test.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { TaskContextMenu } from '@renderer/components/sidebar/TaskContextMenu';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GlobalTask } from '@shared/types';
|
||||
|
||||
const contextMenuMockState = vi.hoisted(() => ({
|
||||
autoOpen: false,
|
||||
}));
|
||||
|
||||
vi.mock('@features/localization/renderer', () => ({
|
||||
useAppTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/context-menu', () => ({
|
||||
ContextMenu: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (contextMenuMockState.autoOpen && open !== true) {
|
||||
onOpenChange?.(true);
|
||||
}
|
||||
}, [onOpenChange, open]);
|
||||
return React.createElement('div', { 'data-context-menu-open': open ? 'true' : 'false' }, children);
|
||||
},
|
||||
ContextMenuTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
ContextMenuContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', { 'data-testid': 'task-context-menu-content' }, children),
|
||||
ContextMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
className?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{ className, type: 'button', onClick: () => onSelect?.() },
|
||||
children
|
||||
),
|
||||
ContextMenuSeparator: () => React.createElement('hr'),
|
||||
}));
|
||||
|
||||
function renderTaskContextMenu(options?: {
|
||||
autoOpen?: boolean;
|
||||
onRename?: () => void;
|
||||
}): {
|
||||
host: HTMLDivElement;
|
||||
root: ReturnType<typeof createRoot>;
|
||||
} {
|
||||
contextMenuMockState.autoOpen = options?.autoOpen ?? false;
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<TaskContextMenu
|
||||
task={{ id: 'task-1' } as GlobalTask}
|
||||
isPinned={false}
|
||||
isArchived={false}
|
||||
onTogglePin={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
onMarkUnread={vi.fn()}
|
||||
onRename={options?.onRename ?? vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
>
|
||||
<span>Task row</span>
|
||||
</TaskContextMenu>
|
||||
);
|
||||
});
|
||||
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe('TaskContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
contextMenuMockState.autoOpen = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not mount menu content while closed', () => {
|
||||
const { host, root } = renderTaskContextMenu();
|
||||
|
||||
expect(host.textContent).toBe('Task row');
|
||||
expect(host.querySelector('[data-testid="task-context-menu-content"]')).toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it('mounts menu content when opened and keeps actions wired', () => {
|
||||
const onRename = vi.fn();
|
||||
const { host, root } = renderTaskContextMenu({ autoOpen: true, onRename });
|
||||
|
||||
const content = host.querySelector('[data-testid="task-context-menu-content"]');
|
||||
expect(content).not.toBeNull();
|
||||
expect(host.textContent).toContain('taskContextMenu.rename');
|
||||
|
||||
const renameButton = [...host.querySelectorAll('button')].find((button) =>
|
||||
button.textContent?.includes('taskContextMenu.rename')
|
||||
);
|
||||
expect(renameButton).not.toBeUndefined();
|
||||
act(() => renameButton?.click());
|
||||
expect(onRename).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue