updatingTasksBridge.test.tsx5.4 KBView on GitHub /**
* The "updating…" affordance, bridged from the Next Steps card to the task surface.
*
* One agent pass rewrites a deal's `nextSteps` prose AND its task rows. Until now only the
* conversation's Next Steps card knew that was happening: the flag lived on the hydrated
* conversation record, and it had exactly one reader. Two consequences, both pinned here:
*
* - The flag has to be settable for a deal the conversation store has never loaded. The task
* list shows tasks across the whole book; almost none of those deals are hydrated, so a
* flag stored on the hydrated record was a silent no-op on the surface that most needed it.
* - A task row reads that same flag and runs the same completion sweep on the same
* `true → false` edge, so the card and the rows land together rather than a beat apart on
* whichever query refetches first.
*
* See NEXT_STEPS_LIFECYCLE_DESIGN.md §2.6.
*/
import { act, render, screen } from '@testing-library/react';
// ── Mocks ─────────────────────────────────────────────────────────────────────
const mockRunCompletionShimmer = jest.fn();
jest.mock('@/lib/completion-shimmer', () => ({
runCompletionShimmer: (...args: unknown[]) => mockRunCompletionShimmer(...args),
}));
jest.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: undefined }),
}));
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({
crm: { getConversation: { queryOptions: () => ({ queryKey: ['getConversation'] }) } },
}),
}));
// The row's /inbox-style thread preload is its own concern (see taskThreadPrefetchHooks.test.tsx);
// stubbed here so this suite doesn't have to stand up a query client to render a row.
jest.mock('@/modules/userTasks/hooks/use-prefetch-task-threads', () => ({
usePrefetchTaskThreads: () => () => {},
usePreloadTaskThreads: () => {},
}));
jest.mock('@/modules/userTasks/hooks/use-task-is-executing', () => ({
useTaskIsExecuting: () => false,
}));
jest.mock('@/modules/conversationsPage/components/ConversationCompanyAvatar', () => ({
ConversationCompanyAvatar: () => null,
}));
import { TaskListRow } from '@/modules/userTasks/components/TaskListRow';
import type { TaskCardTask } from '@/modules/userTasks/components/TaskKanbanCard';
import { useCedarStore } from '@/modules/store';
const CONVERSATION_ID = 'conv-1';
const TASK: TaskCardTask = {
id: 'task-1',
description: 'Send pricing recap',
conversationId: CONVERSATION_ID,
status: 'todo',
dueDate: null,
conversation: { name: 'Acme', companyName: 'Acme', logoUrl: null, lastContactedAt: null, nextStepDate: null, nextSteps: null },
};
const setUpdating = (updating: boolean) =>
act(() => {
useCedarStore.getState().setUpdatingTasks(CONVERSATION_ID, updating);
});
const reset = () =>
act(() => {
useCedarStore.setState((s) => ({ ...s, conversations: {}, updatingTasksByConversation: {} }));
});
beforeEach(() => {
mockRunCompletionShimmer.mockClear();
reset();
});
afterEach(reset);
describe('setUpdatingTasks', () => {
it('sets the flag for a conversation the store has never hydrated', () => {
setUpdating(true);
expect(useCedarStore.getState().updatingTasksByConversation[CONVERSATION_ID]).toBe(true);
});
it('drops the entry rather than storing false, so the map is the in-flight set', () => {
setUpdating(true);
setUpdating(false);
expect(CONVERSATION_ID in useCedarStore.getState().updatingTasksByConversation).toBe(false);
});
it('survives a conversation being (re)hydrated underneath it', () => {
setUpdating(true);
act(() => {
useCedarStore.getState().setConversations({
[CONVERSATION_ID]: {
conversation: { id: CONVERSATION_ID, events: [] },
userTasks: [],
} as never,
});
});
expect(useCedarStore.getState().updatingTasksByConversation[CONVERSATION_ID]).toBe(true);
});
});
// Queried by LABEL, not by text: ShimmerText animates per character and so renders one
// <span> per letter — no text node ever reads "updating". Its wrapper carries
// aria-label={text}. A getByText here fails, and a queryByText passes vacuously.
describe('TaskListRow — the shared affordance', () => {
it('shows the same "updating" cue the Next Steps card shows', () => {
setUpdating(true);
render(<TaskListRow task={TASK} />);
expect(screen.getByLabelText('updating')).toBeInTheDocument();
});
it('shows nothing when no agent pass is running on the deal', () => {
render(<TaskListRow task={TASK} />);
expect(screen.queryByLabelText('updating')).not.toBeInTheDocument();
});
it('sweeps the row on the true → false edge, and only then', () => {
setUpdating(true);
render(<TaskListRow task={TASK} />);
expect(mockRunCompletionShimmer).not.toHaveBeenCalled();
setUpdating(false);
expect(mockRunCompletionShimmer).toHaveBeenCalledTimes(1);
});
it('ignores a pass on a different deal', () => {
render(<TaskListRow task={TASK} />);
act(() => {
useCedarStore.getState().setUpdatingTasks('conv-other', true);
});
expect(screen.queryByLabelText('updating')).not.toBeInTheDocument();
act(() => {
useCedarStore.getState().setUpdatingTasks('conv-other', false);
});
expect(mockRunCompletionShimmer).not.toHaveBeenCalled();
});
});