mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: stop a mail row dragged onto a composer field typing its subject there
This commit is contained in:
@@ -63,7 +63,7 @@ vi.mock('@/hooks/use-deep-link-url', () => ({ useDeepLinkUrl: () => {} }));
|
||||
const { default: ProHome } = await import('../page');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { useProTabStore, takeHandedOffComposeDraft } = await import('@/stores/pro-tab-store');
|
||||
const { PRO_TAB_DRAG_MIME } = await import('@/components/pro/pro-shell-drop');
|
||||
const { EMAIL_IDS_DRAG_MIME, PRO_TAB_DRAG_MIME } = await import('@/components/pro/pro-shell-drop');
|
||||
|
||||
const replace = vi.fn();
|
||||
|
||||
@@ -176,6 +176,24 @@ describe('Pro page gate', () => {
|
||||
expect(useProTabStore.getState().tabs.find((t) => t.id === 'cal')?.paneId).toBe('split');
|
||||
});
|
||||
|
||||
it('stops a message row dropped where it can go nowhere, so no field takes its subject', async () => {
|
||||
auth.state.isAuthenticated = true;
|
||||
auth.state.client = {};
|
||||
const main = { id: 'home-mail', kind: 'mail' as const, labelKey: 'mail', closeable: false, paneId: 'main' as const };
|
||||
useProTabStore.setState({
|
||||
tabs: [main], activeTabId: main.id, activeSplitTabId: null, focusedPaneId: 'main',
|
||||
splitOrientation: null, splitSide: 'right', readerTabId: null, loadedTabIds: ['home-mail'],
|
||||
});
|
||||
const { container } = render(<ProHome />);
|
||||
await act(async () => {});
|
||||
const surface = container.querySelector('[data-testid="mail-app"]')!;
|
||||
const dataTransfer = { types: [EMAIL_IDS_DRAG_MIME, 'text/plain'], getData: () => '["m1"]', dropEffect: '' };
|
||||
// No layout in jsdom: the position resolves to the middle, not a split edge.
|
||||
const notPrevented = fireEvent.drop(surface, { dataTransfer });
|
||||
expect(notPrevented).toBe(false);
|
||||
expect(useProTabStore.getState().tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves a tab dropped on a tab strip to the strip', async () => {
|
||||
auth.state.isAuthenticated = true;
|
||||
auth.state.client = {};
|
||||
|
||||
@@ -433,8 +433,10 @@ export default function ProHome() {
|
||||
if (!kind) return;
|
||||
const target = resolveTargetForEvent(e);
|
||||
setDraggedTabId(null);
|
||||
if (!target) return;
|
||||
// Also with nowhere to put it: a field under the pointer would take the text
|
||||
// a message row carries.
|
||||
e.preventDefault();
|
||||
if (!target) return;
|
||||
|
||||
if (kind === 'tab') {
|
||||
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PaneIdContext } from '@/hooks/use-pane-context';
|
||||
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
@@ -183,6 +184,32 @@ vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) =>
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('a message row dragged over the composer', () => {
|
||||
const rowDrag = { types: ['application/x-email-ids', 'text/plain'], files: [], getData: () => 'Subject of the row', dropEffect: '' };
|
||||
const subjectField = () => screen.getByDisplayValue('Half-written');
|
||||
const draft = {
|
||||
to: '', cc: '', bcc: '', subject: 'Half-written', body: '', showCc: false, showBcc: false,
|
||||
selectedIdentityId: 'id-me', subAddressTag: '', mode: 'compose' as const, draftId: null,
|
||||
};
|
||||
|
||||
it('is stopped in the standard shell, or Subject would take the row\'s subject', () => {
|
||||
render(<EmailComposer initialData={draft} onClose={vi.fn()} />);
|
||||
// fireEvent returns false when the event's default was prevented.
|
||||
expect(fireEvent.dragOver(subjectField(), { dataTransfer: rowDrag })).toBe(false);
|
||||
expect(fireEvent.drop(subjectField(), { dataTransfer: rowDrag })).toBe(false);
|
||||
});
|
||||
|
||||
it('is left to the shell inside a Pro pane', () => {
|
||||
render(
|
||||
<PaneIdContext.Provider value="main">
|
||||
<EmailComposer initialData={draft} onClose={vi.fn()} />
|
||||
</PaneIdContext.Provider>,
|
||||
);
|
||||
expect(fireEvent.dragOver(subjectField(), { dataTransfer: rowDrag })).toBe(true);
|
||||
expect(fireEvent.drop(subjectField(), { dataTransfer: rowDrag })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A host that replaces the compose session - a mailto: click, say - must not
|
||||
* drop text the user has not sent. It hands a follow-up to requestCloseRef and
|
||||
|
||||
@@ -80,6 +80,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { ComposerPanelToggle, ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars";
|
||||
import { dragKindFromTypes } from "@/components/pro/pro-shell-drop";
|
||||
import { useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { ComposerSelectionMenu, useComposerSelectionMenu } from "./composer-selection-menu";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
@@ -170,8 +171,13 @@ import { registerComposeFlush } from "@/lib/compose-flush-registry";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
|
||||
// A Pro tab, message or folder dragged across a composer tab is on its way to
|
||||
// the pane or the split edge: those drags belong to the shell.
|
||||
const isShellDrag = (e: React.DragEvent) => dragKindFromTypes(e.dataTransfer.types) !== null;
|
||||
// the pane or the split edge: those drags belong to the shell. There is no shell
|
||||
// outside Pro, and the composer stops them like any other drag: a message row
|
||||
// carries its subject as text, which To and Subject would take.
|
||||
function useIsShellDrag() {
|
||||
const isPaneScoped = useIsPaneScoped();
|
||||
return useCallback((e: React.DragEvent) => isPaneScoped && dragKindFromTypes(e.dataTransfer.types) !== null, [isPaneScoped]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the text/plain alternative from the composer's HTML body, preserving
|
||||
@@ -2501,6 +2507,7 @@ export function EmailComposer({
|
||||
dragTimeoutRef.current = setTimeout(clearDragState, 150);
|
||||
}, [clearDragState]);
|
||||
|
||||
const isShellDrag = useIsShellDrag();
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
@@ -2509,21 +2516,21 @@ export function EmailComposer({
|
||||
setIsDraggingOver(true);
|
||||
resetDragTimeout();
|
||||
}
|
||||
}, [resetDragTimeout]);
|
||||
}, [isShellDrag, resetDragTimeout]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDragTimeout();
|
||||
}, [resetDragTimeout]);
|
||||
}, [isShellDrag, resetDragTimeout]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDragTimeout();
|
||||
}, [resetDragTimeout]);
|
||||
}, [isShellDrag, resetDragTimeout]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
if (isShellDrag(e)) return;
|
||||
@@ -2533,7 +2540,7 @@ export function EmailComposer({
|
||||
if (e.dataTransfer.files?.length) {
|
||||
addFiles(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
}, [addFiles, clearDragState]);
|
||||
}, [addFiles, clearDragState, isShellDrag]);
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
const att = attachments[index];
|
||||
@@ -6044,6 +6051,7 @@ export function RecipientChipInput({
|
||||
setDropIndex(past ? index + 1 : index);
|
||||
};
|
||||
|
||||
const isShellDrag = useIsShellDrag();
|
||||
// Insert the dragged chip at `target`. Same-field is a local reorder;
|
||||
// cross-field routes through onMoveChip with the destination index (#593).
|
||||
const performDrop = (e: React.DragEvent, target: number) => {
|
||||
|
||||
Reference in New Issue
Block a user