HomeWidgetRail.test.tsx8.0 KBView on GitHub /**
* The home widget rail.
*
* The property worth guarding hardest is the QUERY GATE: the metric tiles are pure
* projections of ONE `statistics.getOverview`, and a rail holding none of them must issue no
* request at all. The obvious refactor — each tile fetching what it needs — turns a glance
* surface into several requests and is invisible until someone opens the network tab.
*/
import { fireEvent, render, screen, within } from '@testing-library/react';
const mockSetWidgetIds = jest.fn();
let mockStoredWidgetIds: readonly string[] | undefined;
jest.mock('@/modules/home/hooks/use-home-setting-list', () => ({
useHomeSettingList: (key=[redacted], fallback: readonly string[]) => ({
// Only the widget list is driven by these tests; the pipeline's AOP scope reads through
// the same hook and must fall through to its own (empty) default.
value: key === 'homeWidgets' ? (mockStoredWidgetIds ?? fallback) : fallback,
setValue: key === 'homeWidgets' ? mockSetWidgetIds : jest.fn(),
loaded: true,
isDefault: mockStoredWidgetIds === undefined,
}),
}));
// The rail resolves the Pipeline widget's scope itself (one query for the whole rail), so it
// reaches for the filters and the AOP list. Neither is what these tests are about.
jest.mock('@/modules/home/hooks/use-home-pipeline-filters', () => ({
useHomePipelineFilters: () => ({ filters: {}, setFilters: jest.fn(), loaded: true }),
}));
// The REAL shape of `aop.listAopsForUser` — `{ aops }`, not a bare array. This mock used to
// return `[]`, which is what let a cast to `{ id, name }[]` sail through CI and then take the
// whole rail down in the browser on `aops[0].id`.
jest.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: { aops: [{ id: 'aop-deals', name: 'Deals' }] }, isLoading: false }),
}));
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({ aop: { listAopsForUser: { queryOptions: () => ({}) } } }),
}));
const mockUseHomeOverview = jest.fn();
jest.mock('@/modules/home/hooks/use-home-overview', () => ({
useHomeOverview: (enabled: boolean, scope?: { aopId?: string | null }) => {
mockUseHomeOverview(enabled, scope?.aopId ?? null);
return { overview: undefined, isLoading: false };
},
}));
// These pull heavy trees of their own (the calendar stack; the agent list and its router).
// Their own behaviour is covered elsewhere; here they stand in as rail contents.
jest.mock('@/modules/home/widgets/HomeMeetingsWidget', () => ({
HomeMeetingsWidget: () => <div data-testid="widget-meetings">MEETINGS</div>,
}));
jest.mock('@/modules/home/widgets/HomeAgentsWidget', () => ({
HomeAgentsWidget: () => <div data-testid="widget-agents">AGENTS</div>,
}));
// The metric tiles reach for a query client of their own (Pipeline lists the user's AOPs to
// scope itself). They stand in as frames here; their contents are covered by their own tests.
jest.mock('@/modules/home/widgets/metric-widgets', () => ({
PipelineWidget: () => <div data-testid="widget-frame" data-widget-title="Pipeline" />,
StatisticsWidget: () => <div data-testid="widget-frame" data-widget-title="Statistics" />,
}));
import { HomeWidgetRail } from '@/modules/home/widgets/HomeWidgetRail';
beforeEach(() => {
mockStoredWidgetIds = undefined;
mockSetWidgetIds.mockClear();
mockUseHomeOverview.mockClear();
});
describe('HomeWidgetRail', () => {
it('starts every user on meetings, pipeline and statistics, in that order', () => {
render(<HomeWidgetRail />);
expect(screen.getByTestId('widget-meetings')).toBeInTheDocument();
const titles = screen
.getAllByTestId('widget-frame')
.map((el) => el.getAttribute('data-widget-title'));
expect(titles).toEqual(['Pipeline', 'Statistics']);
});
it('never offers the unlisted agents tile, but still renders it where it is stored', () => {
// TEMPORARY, while the agents surface is unfinished: nobody can add the tile, and it is
// not in the defaults, so it reaches only the rails that already name it.
mockStoredWidgetIds = ['meetings', 'agents'];
render(<HomeWidgetRail />);
expect(screen.getByTestId('widget-agents')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Edit widgets/ }));
const dialog = screen.getByRole('dialog');
expect(within(dialog).queryByText('Agents')).not.toBeInTheDocument();
});
it('issues NO statistics query for a rail holding no metric tile', () => {
// The gate is the whole reason the metric tiles cost one request between them.
mockStoredWidgetIds = ['meetings'];
render(<HomeWidgetRail />);
expect(mockUseHomeOverview).toHaveBeenCalledWith(false, 'aop-deals');
});
it('issues the shared query once a metric widget is in the rail', () => {
mockStoredWidgetIds = ['meetings', 'pipeline', 'statistics'];
render(<HomeWidgetRail />);
expect(mockUseHomeOverview).toHaveBeenCalledWith(true, 'aop-deals');
// Two metric tiles, ONE call — the hook is what fetches, and there is one of it.
expect(mockUseHomeOverview).toHaveBeenCalledTimes(1);
});
it('scopes the shared query to the deals AOP read off the tRPC result', () => {
// Regression: `aop.listAopsForUser` returns `{ aops }`. Read as a bare array it is not
// empty (an object has no `.length`), the fuzzy match finds nothing, and `aops[0].id`
// threw — killing the whole rail, not just the Pipeline tile.
mockStoredWidgetIds = ['pipeline'];
render(<HomeWidgetRail />);
expect(mockUseHomeOverview).toHaveBeenCalledWith(true, 'aop-deals');
});
it('renders stored widgets in the stored order and skips a retired id', () => {
mockStoredWidgetIds = ['statistics', 'not-a-widget', 'pipeline'];
render(<HomeWidgetRail />);
const titles = screen
.getAllByTestId('widget-frame')
.map((el) => el.getAttribute('data-widget-title'));
expect(titles).toEqual(['Statistics', 'Pipeline']);
});
it('removes through the SAME dialog that adds, not a per-tile control', () => {
mockStoredWidgetIds = ['meetings', 'statistics', 'pipeline'];
render(<HomeWidgetRail />);
// No ✕ on any card: a destructive control under the cursor on the way to every other
// one, and "what is on my rail" split across six places instead of one.
expect(screen.queryByRole('button', { name: /^Remove / })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Edit widgets/ }));
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByText('Meetings'));
expect(mockSetWidgetIds).toHaveBeenCalledWith(['statistics', 'pipeline']);
});
it('adds from the picker by APPENDING, so existing order survives', () => {
mockStoredWidgetIds = ['meetings'];
render(<HomeWidgetRail />);
fireEvent.click(screen.getByRole('button', { name: /Edit widgets/ }));
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByText('Pipeline'));
expect(mockSetWidgetIds).toHaveBeenCalledWith(['meetings', 'pipeline']);
});
it('lists every OFFERED widget in the picker, marking the ones already in the rail', () => {
mockStoredWidgetIds = ['meetings'];
render(<HomeWidgetRail />);
fireEvent.click(screen.getByRole('button', { name: /Edit widgets/ }));
const cards = screen.getAllByTestId('widget-picker-card');
// Every offered widget, not just the addable ones: the same dialog is how you take one
// out. Three, not four — `agents` is unlisted.
expect(cards).toHaveLength(3);
const meetings = cards.find((c) => c.textContent?.includes('Meetings'));
expect(meetings).toHaveAttribute('aria-pressed', 'true');
const pipeline = cards.find((c) => c.textContent?.includes('Pipeline'));
expect(pipeline).toHaveAttribute('aria-pressed', 'false');
});
it('honours an empty rail rather than resurrecting the defaults', () => {
// Absent means "never configured"; empty means "I removed them all".
mockStoredWidgetIds = [];
render(<HomeWidgetRail />);
expect(screen.queryByTestId('widget-frame')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Edit widgets/ })).toBeInTheDocument();
});
});