mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
feat: select every loaded conversation with one click and offer the rest of the folder
This commit is contained in:
@@ -108,6 +108,28 @@ describe('selection bar: Move, read state and the reading pane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('select all in the folder (mail-p2-11)', () => {
|
||||
it('offers the rest of the folder once every loaded row is ticked, and ticks it', async () => {
|
||||
const third = { ...useEmailStore.getState().emails[0], id: 'three', threadId: 't-three', blobId: 'b-three' } as Email;
|
||||
const loadMoreEmails = vi.fn(async () => {
|
||||
useEmailStore.setState({ emails: [...useEmailStore.getState().emails, third], hasMoreEmails: false });
|
||||
});
|
||||
useEmailStore.setState({ hasMoreEmails: true, loadMoreEmails, totalEmails: 3 });
|
||||
render(<EmailList emails={useEmailStore.getState().emails} />);
|
||||
const offer = screen.getByTestId('select-all-in-view');
|
||||
expect(offer.textContent).toBe('mail_p2_list.select_all_in_folder');
|
||||
fireEvent.click(offer.querySelector('button')!);
|
||||
await waitFor(() => expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['one', 'three', 'two']));
|
||||
expect(loadMoreEmails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers nothing while only some loaded rows are ticked', () => {
|
||||
useEmailStore.setState({ hasMoreEmails: true, selectedEmailKeys: new Set(['one']) });
|
||||
render(<EmailList emails={useEmailStore.getState().emails} />);
|
||||
expect(screen.queryByTestId('select-all-in-view')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the reading pane for a selection (mail-p2-15)', () => {
|
||||
it('names its buttons in words, while the bar beside the list keeps icons', () => {
|
||||
const host = document.createElement('div');
|
||||
|
||||
@@ -143,6 +143,9 @@ type ListItem =
|
||||
| { kind: 'header'; section: 'unread' | 'rest' }
|
||||
| { kind: 'unread-empty' };
|
||||
|
||||
/** The most messages "Select all N conversations" loads to tick them (mail-p2-11). */
|
||||
const SELECT_ALL_LIMIT = 5000;
|
||||
|
||||
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
|
||||
const BATCH_TOOLBAR_WIDE = 640;
|
||||
|
||||
@@ -848,6 +851,57 @@ export function EmailList({
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const isEmptyableFolder = currentMailbox?.role === 'trash' || currentMailbox?.role === 'junk';
|
||||
|
||||
// Every loaded row ticked and more on the server: offer the rest, as Gmail
|
||||
// does (mail-p2-11). Taking it loads the remaining pages and ticks them,
|
||||
// so the batch actions, their Undo and their counts work as for any
|
||||
// selection. At most SELECT_ALL_LIMIT messages are loaded for it.
|
||||
const [selectingAll, setSelectingAll] = useState(false);
|
||||
const allLoadedSelected = hasSelection && emails.length > 0 && emails.every((e) => selectedEmailKeys.has(emailKeyFor(e)));
|
||||
const offerSelectAll = allLoadedSelected && footerHasMore && !isScheduledView && !hideBatchToolbar && !!client
|
||||
&& emails.length < SELECT_ALL_LIMIT;
|
||||
const inPlainFolder = !!currentMailbox && !searchQuery.trim() && isFilterEmpty(searchFilters) && !isUnifiedView;
|
||||
const viewTotal = Math.max(
|
||||
inPlainFolder
|
||||
? (disableThreading ? currentMailbox.totalEmails : (currentMailbox.totalThreads || currentMailbox.totalEmails))
|
||||
: totalEmails,
|
||||
selectionRows + 1,
|
||||
);
|
||||
const selectAllLabel = inPlainFolder
|
||||
? tRoot(disableThreading ? 'mail_p2_list.select_all_emails_in_folder' : 'mail_p2_list.select_all_in_folder', {
|
||||
count: viewTotal,
|
||||
folder: localizeMailboxName(currentMailbox.role, currentMailbox.name, (k) => tSidebar(`mailboxes.${k}`)),
|
||||
})
|
||||
: tRoot(disableThreading ? 'mail_p2_list.select_all_emails' : 'mail_p2_list.select_all_conversations', { count: viewTotal });
|
||||
const handleSelectAllInView = async () => {
|
||||
if (!client || selectingAll) return;
|
||||
const view = () => {
|
||||
const state = useEmailStore.getState();
|
||||
return `${state.selectedMailbox}|${state.searchQuery}|${state.isUnifiedView}|${state.unifiedRole}`;
|
||||
};
|
||||
const startedIn = view();
|
||||
setSelectingAll(true);
|
||||
try {
|
||||
for (let pages = 0; pages < 1000; pages++) {
|
||||
const state = useEmailStore.getState();
|
||||
if (view() !== startedIn || state.selectedEmailKeys.size === 0) return;
|
||||
if (!state.hasMoreEmails || state.emails.length >= SELECT_ALL_LIMIT) break;
|
||||
if (state.isLoadingMore) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
continue;
|
||||
}
|
||||
const before = state.emails.length;
|
||||
await state.loadMoreEmails(client);
|
||||
const after = useEmailStore.getState();
|
||||
if (after.emails.length === before && !after.isLoadingMore) break;
|
||||
}
|
||||
// Another folder opened or the selection was cleared meanwhile: leave it.
|
||||
if (view() !== startedIn || useEmailStore.getState().selectedEmailKeys.size === 0) return;
|
||||
useEmailStore.getState().selectAllEmails();
|
||||
} finally {
|
||||
setSelectingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmptyFolder = async () => {
|
||||
if (!client || isProcessing || !currentMailbox) return;
|
||||
|
||||
@@ -1122,7 +1176,7 @@ export function EmailList({
|
||||
inert={!(hasSelection && !isScheduledView && !hideBatchToolbar)}
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
hasSelection && !isScheduledView && !hideBatchToolbar ? (offerSelectAll || selectingAll ? "max-h-28 opacity-100" : "max-h-16 opacity-100") : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
{/* Only drawn while shown: collapsed it would still render every button. */}
|
||||
@@ -1218,6 +1272,19 @@ export function EmailList({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasSelection && !isScheduledView && !hideBatchToolbar && (offerSelectAll || selectingAll) && (
|
||||
<div className="px-4 py-1.5 border-b border-border bg-accent/20 text-center text-sm" data-testid="select-all-in-view">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSelectAllInView()}
|
||||
disabled={selectingAll}
|
||||
aria-busy={selectingAll || undefined}
|
||||
className="text-primary font-medium hover:underline disabled:no-underline disabled:opacity-70"
|
||||
>
|
||||
{selectingAll ? tRoot('mail_p2_list.selecting_all') : selectAllLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Search Filter Chips */}
|
||||
|
||||
@@ -174,6 +174,18 @@ describe('the reading pane for a selection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the list header checkbox (mail-p2-11)', () => {
|
||||
it('ticks every loaded row on the first click and clears them on the second', () => {
|
||||
const second = { ...message, id: 'm2', threadId: 't-m2', subject: 'm2' } as Email;
|
||||
useEmailStore.setState({ emails: [message, second] } as never);
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^email_list\.batch_actions\.select/ }));
|
||||
expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['m1', 'm2']);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'email_list.batch_actions.clear_selection' }));
|
||||
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the new folder dialogs', () => {
|
||||
it.each([
|
||||
['stub new folder', 'mail_list_extra.new_folder_title'],
|
||||
|
||||
@@ -5672,24 +5672,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
{/* Select / Select All toggle */}
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
if (selectedEmailKeys.size > 0) {
|
||||
if (selectedEmailKeys.size === activeEmails.length) {
|
||||
clearSelection();
|
||||
} else {
|
||||
selectAllEmails();
|
||||
}
|
||||
} else if (activeEmails.length > 0) {
|
||||
// The open message when it is in this list, else the
|
||||
// first row - as an email, so the selection is keyed by
|
||||
// its owning account and not by a colliding id.
|
||||
const openKey = selectedEmail ? emailKeyFor(selectedEmail) : null;
|
||||
const target = activeEmails.find((e) => emailKeyFor(e) === openKey) ?? activeEmails[0];
|
||||
toggleEmailSelection(target);
|
||||
}
|
||||
// One click ticks every loaded row, a second clears
|
||||
// them, as Gmail's box does (mail-p2-11). The bar then
|
||||
// offers the rest of the folder.
|
||||
if (selectedEmailKeys.size > 0) clearSelection();
|
||||
else if (activeEmails.length > 0) selectAllEmails();
|
||||
}}
|
||||
disabled={isScheduledView}
|
||||
active={selectedEmailKeys.size > 0}
|
||||
label={selectedEmailKeys.size > 0 ? (selectedEmailKeys.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
|
||||
label={selectedEmailKeys.size > 0 ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')}
|
||||
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||
>
|
||||
{selectedEmailKeys.size > 0 ? <CheckSquare /> : <Square />}
|
||||
|
||||
Reference in New Issue
Block a user