Merge pull request #1338 from HOLOEYE-JZ/feat/plugin-search-results

This commit is contained in:
Linus Rath
2026-10-26 19:32:56 +01:00
14 changed files with 710 additions and 32 deletions
@@ -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');
});
});
+6
View File
@@ -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} />
)}
+168
View File
@@ -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>
);
}
+1
View File
@@ -5215,6 +5215,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}}
onEmailSelect={handleEmailSelect}
onOpenSearchResult={handleEmailSelect}
onEmailPrefetch={handleEmailPrefetch}
onEmailDoubleClick={isEmbedded ? ((email) => {
const store = useProTabStore.getState();