mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: tell screen readers when a draft is saved and read a failed upload out once
This commit is contained in:
@@ -138,8 +138,9 @@ vi.mock('@/stores/template-store', () => {
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
toast: { info: () => {}, error: toastError, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
@@ -394,14 +395,17 @@ describe('attachment uploads (SR-16)', () => {
|
||||
expect(bar).toHaveAttribute('aria-valuetext', 'attachment_progress:45');
|
||||
});
|
||||
|
||||
it('says in words that an upload failed and announces it', async () => {
|
||||
// The error toast is a live region of its own; the composer saying it
|
||||
// too would read the failure twice.
|
||||
it('says in words that an upload failed and reports it once', async () => {
|
||||
uploadClient(() => Promise.reject(new Error('boom')));
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
await attach(new File(['%PDF'], 'report.pdf', { type: 'application/pdf' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('attachment_upload_failed')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
expect(liveRegion()).toHaveTextContent('upload_failed:report.pdf');
|
||||
expect(toastError).toHaveBeenCalledWith('upload_failed:report.pdf');
|
||||
expect(liveRegion()).not.toHaveTextContent('upload_failed');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { LIVE_REGION_IDS } from '@/lib/announce';
|
||||
|
||||
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
|
||||
|
||||
@@ -322,6 +323,28 @@ describe('composer close guard with a follow-up', () => {
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('tells screen readers the draft was saved when the user saves, not on autosave', async () => {
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-9');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
const polite = () => document.getElementById(LIVE_REGION_IDS.polite);
|
||||
try {
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(polite()?.textContent ?? '').not.toContain('draft_saved');
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
fireEvent.click(within(await screen.findByRole('alertdialog')).getByText('save'));
|
||||
|
||||
await waitFor(() => expect(polite()?.textContent).toContain('draft_saved'));
|
||||
} finally {
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the follow-up when the save fails and the rescue takes over', async () => {
|
||||
// When the server refuses the draft, the composer text survives only via
|
||||
// the unmount stash into the continue-draft slot (#702). That rescue
|
||||
|
||||
@@ -141,6 +141,7 @@ import { isValidEmail } from "@/lib/validation";
|
||||
import { composeTextStyle, composeTextStyleCss } from "@/components/email/font-size";
|
||||
import { buildMentionCandidates, type MentionCandidate } from "@/lib/recipient-mentions";
|
||||
import { describeRecipientChanges, recipientDisplayName, type RecipientChange, type RecipientFields } from "@/lib/recipient-announcements";
|
||||
import { announce as announceStatus } from "@/lib/announce";
|
||||
import { normalizeCid } from "@/lib/attachment-visibility";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import { isRecipientMentionActive } from "@/components/email/recipient-mention";
|
||||
@@ -2277,8 +2278,8 @@ export function EmailComposer({
|
||||
} catch (error) {
|
||||
if (controller?.signal.aborted) continue;
|
||||
debug.error(`Failed to upload ${file.name}:`, error);
|
||||
// The error toast reads itself out (components/ui/toast.tsx).
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
announce(t('upload_failed', { filename: file.name }));
|
||||
|
||||
setAttachments(prev =>
|
||||
prev.map(att =>
|
||||
@@ -2289,7 +2290,7 @@ export function EmailComposer({
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [client, t, plainTextMode, announce]);
|
||||
}, [client, t, plainTextMode]);
|
||||
|
||||
const handleImageUpload = useCallback(async (
|
||||
file: File,
|
||||
@@ -2745,13 +2746,17 @@ export function EmailComposer({
|
||||
// racing two `Email/set { destroy, create }` requests against the same
|
||||
// draftId, which left orphan drafts and (when EmailSubmission failed)
|
||||
// looked like "send didn't happen" (#303).
|
||||
const saveDraft = (opts: { auto?: boolean } = {}): Promise<string | null> => {
|
||||
const saveDraft = (opts: { auto?: boolean; announce?: boolean } = {}): Promise<string | null> => {
|
||||
const previous = inflightSaveRef.current;
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
if (previous) {
|
||||
try { await previous; } catch { /* prior failure already reported */ }
|
||||
}
|
||||
return saveDraftOnce(!!opts.auto);
|
||||
const id = await saveDraftOnce(!!opts.auto);
|
||||
// Only the save the user asked for is read out: autosave would say it
|
||||
// every minute, and the save before sending is followed by "Sent".
|
||||
if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved'));
|
||||
return id;
|
||||
})();
|
||||
inflightSaveRef.current = promise;
|
||||
promise.finally(() => {
|
||||
@@ -3777,7 +3782,7 @@ export function EmailComposer({
|
||||
// close attempt just re-opened this dialog - Discard was the only way out
|
||||
// (#702). Requests carry a deadline now, so a broken save resolves.
|
||||
try {
|
||||
await saveDraft();
|
||||
await saveDraft({ announce: true });
|
||||
if (saveFailedRef.current) {
|
||||
// The server never took the draft. Clearing stateRef here would drop the
|
||||
// message on the floor with nothing but a toast, so leave the text in
|
||||
|
||||
Reference in New Issue
Block a user