Fix cashflow null category rows (#382)

## Sentry
- Issue: PHP-LARAVEL-1T
- URL: https://whisper-money.sentry.io/issues/PHP-LARAVEL-1T

## Root cause
Cashflow breakdown UI assumed every API row has a category object.
Production returned a breakdown row with `category: null`, so rendering
read `item.category.icon` and crashed.

## Fix
- Allow breakdown rows to carry nullable category/category_id.
- Render null categories as Uncategorized with HelpCircle/gray fallback.
- Add focused Vitest regression.
- Enable Wayfinder Vite plugin in Vitest so components importing
generated actions resolve in tests.

## Verification
- `npm test -- resources/js/components/cashflow/breakdown-card.test.tsx`
- `npx prettier --write
resources/js/components/cashflow/breakdown-card.tsx
resources/js/components/cashflow/breakdown-card.test.tsx
resources/js/hooks/use-cashflow-data.ts vitest.config.ts`
- `npx eslint resources/js/components/cashflow/breakdown-card.tsx
resources/js/components/cashflow/breakdown-card.test.tsx
resources/js/hooks/use-cashflow-data.ts vitest.config.ts --fix`

## Notes
- `npm run types` currently fails on existing unrelated TypeScript
errors across account, transaction, chart, auth, crypto, and dexie
files.
This commit is contained in:
Víctor Falcón 2026-05-11 17:54:26 +01:00 committed by GitHub
parent e635fdad5c
commit 30cc4da6c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 68 additions and 8 deletions

View File

@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import type React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { BreakdownCard } from './breakdown-card';
vi.mock('@/components/ui/amount-display', () => ({
AmountDisplay: ({ amountInCents }: { amountInCents: number }) => (
<span>{amountInCents}</span>
),
}));
vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
index: () => ({ url: '/transactions' }),
}));
vi.mock('@inertiajs/react', () => ({
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
usePage: () => ({ props: { chartColorScheme: 'colorful' } }),
}));
describe('BreakdownCard', () => {
it('renders uncategorized rows when the API returns a null category', () => {
render(
<BreakdownCard
type="expense"
data={{
data: [
{
category: null,
category_id: null,
amount: 12345,
percentage: 100,
previous_amount: 0,
},
],
total: 12345,
previous_total: 0,
}}
currency="USD"
/>,
);
expect(screen.getByText('Uncategorized')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
});
});

View File

@ -12,7 +12,11 @@ import { Progress } from '@/components/ui/progress';
import { BreakdownData } from '@/hooks/use-cashflow-data';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { cn } from '@/lib/utils';
import { getCategoryColorClasses } from '@/types/category';
import {
type CategoryColor,
type CategoryIcon,
getCategoryColorClasses,
} from '@/types/category';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { format } from 'date-fns';
@ -27,6 +31,12 @@ interface BreakdownCardProps {
period?: { from: Date; to: Date };
}
const fallbackCategory = {
name: __('Uncategorized'),
icon: 'HelpCircle' as CategoryIcon,
color: 'gray' as CategoryColor,
};
export function BreakdownCard({
type,
data,
@ -89,8 +99,9 @@ export function BreakdownCard({
<CardContent>
<div className="space-y-4">
{data.data.map((item, index) => {
const category = item.category ?? fallbackCategory;
const Icon = (Icons[
item.category.icon as keyof typeof Icons
category.icon as keyof typeof Icons
] || Icons.HelpCircle) as LucideIcon;
const percentageChange =
@ -101,10 +112,10 @@ export function BreakdownCard({
: null;
const categoryColor = getCategoryColorClasses(
item.category.color,
category.color,
);
const chartColor = categoryBarColor(
item.category.color,
category.color,
index,
);
@ -134,7 +145,7 @@ export function BreakdownCard({
<Icon className="size-3.5" />
</div>
<span className="min-w-0 truncate text-sm font-medium">
{item.category.name}
{category.name}
</span>
</div>
<div className="flex gap-2">

View File

@ -30,8 +30,8 @@ export interface TrendDataPoint {
}
export interface BreakdownItem {
category: Category;
category_id: string;
category: Category | null;
category_id: string | null;
amount: number;
percentage: number;
previous_amount: number;

View File

@ -1,9 +1,10 @@
import { wayfinder } from '@laravel/vite-plugin-wayfinder';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
plugins: [react(), wayfinder({ formVariants: true })],
test: {
environment: 'jsdom',
globals: true,