mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
Merge pull request #1338 from HOLOEYE-JZ/feat/plugin-search-results
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { setActionDispatcher } from '@/lib/plugin-sandbox/actions';
|
||||
import type { ProvidedSearchResult } from '@/lib/plugin-search-results';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { PluginSearchResults } from '../plugin-search-results';
|
||||
|
||||
// The block above a search's message list with the entries plugins returned
|
||||
// from onProvideSearchResults; a click goes back to the plugin as onAction,
|
||||
// or opens the message an entry names.
|
||||
|
||||
const result = (pluginId: string, key: string, source?: string): ProvidedSearchResult =>
|
||||
({ pluginId, key, title: `Title ${key}`, snippet: `Snippet ${key}`, ...(source ? { source } : {}) });
|
||||
|
||||
const dispatch = vi.fn(async () => {});
|
||||
|
||||
/** A login whose primary account is `primary`, reading `shared` accounts too. */
|
||||
const client = (primary: string, shared: string[] = []) => ({
|
||||
getAccountId: () => primary,
|
||||
getSharedAccounts: () => [primary, ...shared].map((id) => ({ id, name: id, isPrimary: id === primary, capabilities: { mail: true } })),
|
||||
});
|
||||
|
||||
const withEmail = (key: string, email: { id: string; accountId?: string }): ProvidedSearchResult =>
|
||||
({ ...result('crm', key, 'CRM'), email });
|
||||
|
||||
beforeEach(() => {
|
||||
setActionDispatcher(dispatch);
|
||||
usePluginStore.setState({ plugins: [{ id: 'chat', name: 'Team chat' }] } as never);
|
||||
useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, selectedEmail: null });
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'login-1',
|
||||
getAllConnectedClients: () => new Map([['login-2', client('acc-2')], ['login-1', client('acc-1', ['group-1'])]]),
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActionDispatcher(null);
|
||||
dispatch.mockClear();
|
||||
});
|
||||
|
||||
describe('PluginSearchResults', () => {
|
||||
it('shows nothing outside a search', () => {
|
||||
useEmailStore.setState({ searchQuery: '', externalSearchResults: [result('crm', 'a', 'CRM')] });
|
||||
const { container } = render(<PluginSearchResults surface="search-results" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('shows the entries of a search by filters alone', () => {
|
||||
useEmailStore.setState({
|
||||
searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'bob@x.test' },
|
||||
externalSearchResults: [result('crm', 'a', 'CRM')],
|
||||
});
|
||||
render(<PluginSearchResults surface="search-results" />);
|
||||
expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title a');
|
||||
});
|
||||
|
||||
it('groups the entries by source, the plugin\'s name without one', () => {
|
||||
useEmailStore.setState({
|
||||
searchQuery: 'resolution',
|
||||
externalSearchResults: [result('crm', 'a', 'CRM'), result('chat', 'b'), result('crm', 'c', 'CRM')],
|
||||
});
|
||||
render(<PluginSearchResults surface="search-results" />);
|
||||
|
||||
expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title a');
|
||||
expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title c');
|
||||
expect(screen.getByRole('region', { name: 'Team chat' })).toHaveTextContent('Snippet b');
|
||||
});
|
||||
|
||||
it('sends a click to the plugin that gave the entry, with the query', () => {
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [result('crm', 'mail-123', 'CRM')] });
|
||||
render(<PluginSearchResults surface="search-results" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title mail-123/ }));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('crm', 'search-results', 'mail-123', { query: 'resolution' });
|
||||
});
|
||||
|
||||
it('opens the message an entry names in the reader, not the plugin', () => {
|
||||
const open = vi.fn();
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [withEmail('mail-1', { id: 'M1' })] });
|
||||
render(<PluginSearchResults surface="search-results" onOpenEmail={open} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title mail-1/ }));
|
||||
|
||||
expect(open).toHaveBeenCalledWith({ id: 'M1', sourceClientAccountId: 'login-1', sourceAccountId: 'acc-1' });
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a message of a shared account through the login reaching it, the active one first', () => {
|
||||
const open = vi.fn();
|
||||
useEmailStore.setState({
|
||||
searchQuery: 'resolution',
|
||||
externalSearchResults: [withEmail('g', { id: 'M1', accountId: 'group-1' }), withEmail('o', { id: 'M2', accountId: 'acc-2' })],
|
||||
});
|
||||
render(<PluginSearchResults surface="search-results" onOpenEmail={open} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title g/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title o/ }));
|
||||
|
||||
expect(open).toHaveBeenNthCalledWith(1, { id: 'M1', sourceClientAccountId: 'login-1', sourceAccountId: 'group-1' });
|
||||
expect(open).toHaveBeenNthCalledWith(2, { id: 'M2', sourceClientAccountId: 'login-2', sourceAccountId: 'acc-2' });
|
||||
});
|
||||
|
||||
it('sends the click to the plugin when no login reaches the message\'s account', () => {
|
||||
const open = vi.fn();
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [withEmail('mail-1', { id: 'M1', accountId: 'elsewhere' })] });
|
||||
render(<PluginSearchResults surface="search-results" onOpenEmail={open} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title mail-1/ }));
|
||||
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith('crm', 'search-results', 'mail-1', { query: 'resolution' });
|
||||
});
|
||||
|
||||
it('marks the entry whose message is open, in the same account', () => {
|
||||
useEmailStore.setState({
|
||||
searchQuery: 'resolution',
|
||||
externalSearchResults: [withEmail('a', { id: 'M1', accountId: 'group-1' }), withEmail('b', { id: 'M1' })],
|
||||
selectedEmail: { id: 'M1', sourceAccountId: 'group-1' } as Email,
|
||||
});
|
||||
render(<PluginSearchResults surface="search-results" onOpenEmail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Title a/ })).toHaveAttribute('aria-current', 'true');
|
||||
expect(screen.getByRole('button', { name: /Title b/ })).not.toHaveAttribute('aria-current');
|
||||
|
||||
act(() => useEmailStore.setState({ selectedEmail: { id: 'M1' } as Email }));
|
||||
expect(screen.getByRole('button', { name: /Title a/ })).not.toHaveAttribute('aria-current');
|
||||
expect(screen.getByRole('button', { name: /Title b/ })).toHaveAttribute('aria-current', 'true');
|
||||
});
|
||||
|
||||
it('folds a group', () => {
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [result('crm', 'a', 'CRM')] });
|
||||
render(<PluginSearchResults surface="search-results" />);
|
||||
|
||||
const heading = screen.getByRole('button', { name: /^CRM/ });
|
||||
expect(heading).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(heading);
|
||||
|
||||
expect(screen.queryByText('Title a')).toBeNull();
|
||||
expect(heading).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
@@ -42,12 +42,15 @@ import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
||||
import { unreadSectionRowCount } from "@/lib/unread-first";
|
||||
import { MvListSectionHeader } from "./mv-list-section-header";
|
||||
import { type OpenableEmail, PluginSearchResults } from "./plugin-search-results";
|
||||
|
||||
interface EmailListProps {
|
||||
emails: Email[];
|
||||
/** emailKeyFor of the open message: ids repeat across accounts. */
|
||||
selectedEmailKey?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
/** Opens a message a plugin's search result names (PluginSearchResults). */
|
||||
onOpenSearchResult?: (email: OpenableEmail) => void;
|
||||
/** A mouse was pressed on a row: the click that follows will open it. */
|
||||
onEmailPrefetch?: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
@@ -128,6 +131,7 @@ export function EmailList({
|
||||
emails,
|
||||
selectedEmailKey,
|
||||
onEmailSelect,
|
||||
onOpenSearchResult,
|
||||
onEmailPrefetch,
|
||||
onEmailDoubleClick,
|
||||
onFindFromSender,
|
||||
@@ -974,6 +978,8 @@ export function EmailList({
|
||||
|
||||
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
|
||||
|
||||
<PluginSearchResults surface="search-results" onOpenEmail={onOpenSearchResult} />
|
||||
|
||||
{listColumns && (
|
||||
<ListColumnsHeader density={density} selecting={hasSelection} showRecipient={showRecipientColumn} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
import { runPluginAction } from "@/lib/plugin-sandbox/actions";
|
||||
import type { ProvidedSearchResult } from "@/lib/plugin-search-results";
|
||||
import type { SearchResultEmail } from "@/lib/plugin-types";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
heading: string;
|
||||
results: ProvidedSearchResult[];
|
||||
}
|
||||
|
||||
/** One group per plugin and source, in the order the entries came. */
|
||||
function groupResults(results: ProvidedSearchResult[], pluginName: (id: string) => string): Group[] {
|
||||
const groups = new Map<string, Group>();
|
||||
for (const r of results) {
|
||||
const id = `${r.pluginId}\u0000${r.source ?? ""}`;
|
||||
let group = groups.get(id);
|
||||
if (!group) {
|
||||
group = { id, heading: r.source ?? pluginName(r.pluginId), results: [] };
|
||||
groups.set(id, group);
|
||||
}
|
||||
group.results.push(r);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/** A message as the reader opens one that is not a row of the list: the login reaching it and its account. */
|
||||
export type OpenableEmail = Required<Pick<Email, "id" | "sourceClientAccountId" | "sourceAccountId">>;
|
||||
|
||||
/**
|
||||
* The login through which the user reaches a message an entry names, the
|
||||
* active one first; null when none does (an account the user cannot read).
|
||||
* Without an account the message is the active login's own.
|
||||
*/
|
||||
export function reachableEmail(email: SearchResultEmail): OpenableEmail | null {
|
||||
const auth = useAuthStore.getState();
|
||||
const clients = [...auth.getAllConnectedClients()]
|
||||
.sort(([a], [b]) => Number(b === auth.activeAccountId) - Number(a === auth.activeAccountId));
|
||||
for (const [login, client] of clients) {
|
||||
if (!email.accountId) {
|
||||
return login === auth.activeAccountId
|
||||
? { id: email.id, sourceClientAccountId: login, sourceAccountId: client.getAccountId() }
|
||||
: null;
|
||||
}
|
||||
const reaches = client.getAccountId() === email.accountId
|
||||
|| client.getSharedAccounts().some((a) => a.id === email.accountId && a.capabilities.mail);
|
||||
if (reaches) return { id: email.id, sourceClientAccountId: login, sourceAccountId: email.accountId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The active login's own account. */
|
||||
function ownAccountId(): string | undefined {
|
||||
const auth = useAuthStore.getState();
|
||||
return auth.activeAccountId ? auth.getAllConnectedClients().get(auth.activeAccountId)?.getAccountId() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the message open in the reader is the one an entry names. JMAP ids
|
||||
* are unique per account only, so the accounts are compared too, a missing
|
||||
* one being the active login's own.
|
||||
*/
|
||||
function isOpen(email: SearchResultEmail | undefined, open: Email | null): boolean {
|
||||
if (!email || !open || open.id !== email.id) return false;
|
||||
const own = ownAccountId();
|
||||
return (open.sourceAccountId ?? own) === (email.accountId ?? own);
|
||||
}
|
||||
|
||||
interface PluginSearchResultsProps {
|
||||
/** Entries plugins return from onProvideSearchResults; a click is the plugin's onAction. */
|
||||
surface: "search-results";
|
||||
/** Opens a message in the reader, as a click on a list row does; without it every click is onAction. */
|
||||
onOpenEmail?: (email: OpenableEmail) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Results from elsewhere for the search that is running (CRM hits, chat
|
||||
* messages, ...), above its message list. Renders nothing outside a search or
|
||||
* without entries. A click on an entry naming a message the user reaches opens
|
||||
* it; any other goes back to the plugin that gave the entry.
|
||||
*/
|
||||
export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResultsProps) {
|
||||
const results = useEmailStore((s) => s.externalSearchResults);
|
||||
const openEmail = useEmailStore((s) => s.selectedEmail);
|
||||
const query = useEmailStore((s) => s.searchQuery);
|
||||
const searching = useEmailStore((s) => !!s.searchQuery.trim() || !isFilterEmpty(s.searchFilters));
|
||||
const plugins = usePluginStore((s) => s.plugins);
|
||||
const idPrefix = useId();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupResults(results, (id) => plugins.find((p) => p.id === id)?.name ?? id),
|
||||
[results, plugins],
|
||||
);
|
||||
|
||||
if (!searching || groups.length === 0) return null;
|
||||
|
||||
const click = (r: ProvidedSearchResult) => {
|
||||
const reachable = r.email && onOpenEmail ? reachableEmail(r.email) : null;
|
||||
if (reachable) onOpenEmail?.(reachable);
|
||||
else void runPluginAction(r.pluginId, surface, r.key, { query });
|
||||
};
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-plugin-surface={surface} className="max-h-[40%] flex-shrink-0 overflow-y-auto border-b border-border bg-muted/30">
|
||||
{groups.map((group, index) => {
|
||||
const open = !collapsed.has(group.id);
|
||||
const listId = `${idPrefix}-${index}`;
|
||||
return (
|
||||
<section key={group.id} aria-label={group.heading}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(group.id)}
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5 rtl:-scale-x-100" />}
|
||||
<span className="truncate">{group.heading}</span>
|
||||
<span className="font-normal">({group.results.length})</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul id={listId} className="pb-1">
|
||||
{group.results.map((r) => {
|
||||
const current = isOpen(r.email, openEmail);
|
||||
return (
|
||||
<li key={r.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => click(r)}
|
||||
aria-current={current ? "true" : undefined}
|
||||
className={cn(
|
||||
"flex w-full flex-col items-start gap-0.5 px-3 py-1.5 text-start",
|
||||
"hover:bg-muted focus-visible:bg-muted focus-visible:outline-none",
|
||||
current && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate text-sm font-medium text-foreground">{r.title}</span>
|
||||
{r.snippet && <span className="line-clamp-2 text-xs text-muted-foreground">{r.snippet}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5215,6 +5215,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onOpenSearchResult={handleEmailSelect}
|
||||
onEmailPrefetch={handleEmailPrefetch}
|
||||
onEmailDoubleClick={isEmbedded ? ((email) => {
|
||||
const store = useProTabStore.getState();
|
||||
|
||||
Reference in New Issue
Block a user