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:
parent
e635fdad5c
commit
30cc4da6c6
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue