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 { useAppTranslation } from '@features/localization/renderer';
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
|
|
@ -34,63 +36,66 @@ export const TaskContextMenu = ({
|
||||||
children,
|
children,
|
||||||
}: TaskContextMenuProps): React.JSX.Element => {
|
}: TaskContextMenuProps): React.JSX.Element => {
|
||||||
const { t } = useAppTranslation('common');
|
const { t } = useAppTranslation('common');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu open={open} onOpenChange={setOpen}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div className="w-full">{children}</div>
|
<div className="w-full">{children}</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
{open ? (
|
||||||
<ContextMenuItem onSelect={onTogglePin}>
|
<ContextMenuContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
{isPinned ? (
|
<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" />
|
<ContextMenuSeparator />
|
||||||
<span>{t('taskContextMenu.unpin')}</span>
|
<ContextMenuItem onSelect={onDelete} className="text-red-400 focus:text-red-400">
|
||||||
</>
|
<Trash2 className="size-3.5 shrink-0" />
|
||||||
) : (
|
<span>{t('taskContextMenu.deleteTask')}</span>
|
||||||
<>
|
</ContextMenuItem>
|
||||||
<Pin className="size-3.5 shrink-0" />
|
|
||||||
<span>{t('taskContextMenu.pin')}</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ContextMenuItem>
|
</ContextMenuContent>
|
||||||
|
) : null}
|
||||||
<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>
|
|
||||||
</ContextMenu>
|
</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