channelThreadRemind.test.tsx7.8 KBView on GitHub /**
* "Remind me" in an open Slack / LinkedIn / WhatsApp chat.
*
* The header's three controls are Star, Remind me and Mark done. Remind me shipped with the
* tooltip, the icon and the hover state of a working control and no `onClick` at all —
* `ActionButton`'s handler is optional, so it rendered, tooltipped, and did nothing. Nothing
* caught it: it type-checks, it lints, and a screenshot of the header looks correct.
*
* A chat's reminder IS its snooze (`inbox.setItemState`), the same write the row's clock icon
* makes — so this pins both halves: the button opens the picker, and picking a date snoozes
* THIS item to THAT date and closes the chat, exactly as Mark done does.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
// ── Module mocks (must precede the imports they affect) ─────────────────────
const mockSnooze = jest.fn();
const mockMarkDone = jest.fn();
const mockToggleStar = jest.fn();
const mockMarkChannelRead = jest.fn();
jest.mock('@/modules/inbox/hooks/use-inbox-item-actions', () => ({
useInboxItemActions: () => ({ snooze: mockSnooze, markDone: mockMarkDone, toggleStar: mockToggleStar, markChannelRead: mockMarkChannelRead }),
}));
/**
* A tRPC stand-in that answers ANY route path. The view reads from half a dozen routers
* (linkedin, outbound.whatsapp, inbox, crm, channels, …) and this test is about one button —
* enumerating them would be a list to maintain, not a thing being asserted.
*/
jest.mock('@/providers/query-provider', () => {
const leaf = {
queryOptions: (input: unknown) => ({
queryKey: ['stub', input],
queryFn: () => Promise.resolve(null),
}),
mutationOptions: () => ({ mutationFn: () => Promise.resolve({ ok: true }) }),
};
const proxy: unknown = new Proxy(leaf, {
get: (target, prop) =>
prop in target ? target[prop as keyof typeof target] : (proxy as Record<string, unknown>),
});
return { useTRPC: () => proxy };
});
jest.mock('@/hooks/use-connections', () => ({
useActiveConnection: () => ({ data: { email: '<email>', picture: null } }),
}));
jest.mock('@/modules/auth/utils/auth-client', () => ({
useSession: () => ({ data: { user: { image: null } } }),
}));
jest.mock('@/modules/inbox/hooks/use-channel-reactions', () => ({
useChannelReactions: () => ({ react: jest.fn(), unreact: jest.fn() }),
}));
jest.mock('@/modules/inbox/hooks/use-chat-reactions', () => ({
useChatReactions: () => ({ react: jest.fn(), unreact: jest.fn() }),
}));
jest.mock('@/modules/conversations/components/timeline/use-slack-principals', () => ({
useSlackPrincipalsForMessages: () => ({ resolveAvatar: () => undefined, resolveName: () => undefined }),
}));
// Heavy children with their own data needs — none of them is what this asserts.
jest.mock('@/modules/inbox/components/ChannelMessageList', () => ({
ChannelMessageList: () => <div data-testid="messages" />,
}));
jest.mock('@/modules/conversations/components/timeline/composer/SlackMentionTextarea', () => ({
SlackMentionTextarea: () => <textarea data-testid="composer" />,
}));
jest.mock('@/modules/inbox/components/SlackThreadPanel', () => ({
SlackThreadPanel: () => <div />,
}));
jest.mock('@/modules/inbox/components/AttachToConversation', () => ({
AttachToConversation: () => <div />,
}));
jest.mock('@/modules/conversationsPage/components/ConversationCompanyAvatar', () => ({
ConversationCompanyAvatar: () => <span />,
}));
// Not for speed: this import chain reaches TipTap's emoji extension, which probes a canvas at
// REQUIRE time. jsdom has no 2d context, so the probe leaves the worker unable to exit cleanly
// — visible only in a full parallel run, as a force-exit warning on the whole suite.
jest.mock('@/modules/conversations/components/timeline/SlackStructuredBody', () => ({
renderSlackBody: (text: string) => text,
}));
// ── Imports (after mocks) ───────────────────────────────────────────────────
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { TooltipProvider } from '@/components/ui/tooltip';
import { ChannelThreadView } from '@/modules/inbox/components/ChannelThreadView';
import type { InboxItem } from '@/modules/inbox/types';
// ── Fixtures ────────────────────────────────────────────────────────────────
const slackItem = {
id: 'slack:W1:C1',
channel: 'slack',
ref: { kind: 'slack', workspaceId: 'W1', slackChannelId: 'C1', slackUserId: 'U1' },
counterpart: { name: 'Dana', subtitle: '#acme-cedar', email: null },
snippet: 'ping',
sortedAt: '2026-08-16T10:00:00.000Z',
starred: false,
unread: false,
conversationId: null,
} as unknown as InboxItem;
function renderChat(mounted: Array<() => void>, onClose = jest.fn()) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const { unmount } = render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<ChannelThreadView item={slackItem} onClose={onClose} />
</TooltipProvider>
</QueryClientProvider>,
);
// Radix's dialog holds focus-trap and scroll-lock work that outlives an implicit teardown;
// tearing it down inside the test keeps the worker able to exit.
mounted.push(() => {
queryClient.clear();
unmount();
});
return { onClose };
}
// ── Suite ───────────────────────────────────────────────────────────────────
describe('ChannelThreadView — Remind me', () => {
// cmdk scrolls its active option into view; jsdom has no such method.
beforeAll(() => {
Element.prototype.scrollIntoView = jest.fn();
});
const mounted: Array<() => void> = [];
afterEach(() => {
while (mounted.length) mounted.pop()!();
});
beforeEach(() => {
mockSnooze.mockClear();
mockMarkDone.mockClear();
mockToggleStar.mockClear();
});
it('opens the date picker (the button is wired at all)', () => {
renderChat(mounted);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText('Remind me'));
// The picker's own title, not the button's tooltip.
expect(screen.getByRole('dialog')).toHaveTextContent('Remind me at');
});
it('snoozes THIS chat to the picked date and closes the view', async () => {
const { onClose } = renderChat(mounted);
fireEvent.click(screen.getByLabelText('Remind me'));
// Type rather than take the first default row, so the date being carried through is one
// this test actually names.
fireEvent.change(screen.getByPlaceholderText(/Try:/), { target: { value: 'tomorrow' } });
// A bare date with no hour also offers a "<input> at 8am" variant (`parsed-N-8am`,
// modules/crm/utils/time.ts), so an unanchored /tomorrow/i matches two options. The
// accessible name carries the sublabel date too, hence the lookahead rather than `$`:
// this test means the option it typed, not the 8am one.
fireEvent.click(await screen.findByRole('option', { name: /^tomorrow(?! at )/i }));
// `DatePickerDialog` closes first and calls back on the next frame, deliberately —
// so that snoozing can unmount this view without fighting Radix's overlay teardown.
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
const [item, when] = mockSnooze.mock.calls[0];
expect(item.id).toBe('slack:W1:C1');
expect(when).toBeInstanceOf(Date);
expect(when.getTime()).toBeGreaterThan(Date.now());
expect(onClose).toHaveBeenCalled();
});
});