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 { BreakdownData } from '@/hooks/use-cashflow-data';
|
||||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getCategoryColorClasses } from '@/types/category';
|
import {
|
||||||
|
type CategoryColor,
|
||||||
|
type CategoryIcon,
|
||||||
|
getCategoryColorClasses,
|
||||||
|
} from '@/types/category';
|
||||||
import { __ } from '@/utils/i18n';
|
import { __ } from '@/utils/i18n';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
@ -27,6 +31,12 @@ interface BreakdownCardProps {
|
||||||
period?: { from: Date; to: Date };
|
period?: { from: Date; to: Date };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackCategory = {
|
||||||
|
name: __('Uncategorized'),
|
||||||
|
icon: 'HelpCircle' as CategoryIcon,
|
||||||
|
color: 'gray' as CategoryColor,
|
||||||
|
};
|
||||||
|
|
||||||
export function BreakdownCard({
|
export function BreakdownCard({
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
|
|
@ -89,8 +99,9 @@ export function BreakdownCard({
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.data.map((item, index) => {
|
{data.data.map((item, index) => {
|
||||||
|
const category = item.category ?? fallbackCategory;
|
||||||
const Icon = (Icons[
|
const Icon = (Icons[
|
||||||
item.category.icon as keyof typeof Icons
|
category.icon as keyof typeof Icons
|
||||||
] || Icons.HelpCircle) as LucideIcon;
|
] || Icons.HelpCircle) as LucideIcon;
|
||||||
|
|
||||||
const percentageChange =
|
const percentageChange =
|
||||||
|
|
@ -101,10 +112,10 @@ export function BreakdownCard({
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const categoryColor = getCategoryColorClasses(
|
const categoryColor = getCategoryColorClasses(
|
||||||
item.category.color,
|
category.color,
|
||||||
);
|
);
|
||||||
const chartColor = categoryBarColor(
|
const chartColor = categoryBarColor(
|
||||||
item.category.color,
|
category.color,
|
||||||
index,
|
index,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -134,7 +145,7 @@ export function BreakdownCard({
|
||||||
<Icon className="size-3.5" />
|
<Icon className="size-3.5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="min-w-0 truncate text-sm font-medium">
|
<span className="min-w-0 truncate text-sm font-medium">
|
||||||
{item.category.name}
|
{category.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ export interface TrendDataPoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BreakdownItem {
|
export interface BreakdownItem {
|
||||||
category: Category;
|
category: Category | null;
|
||||||
category_id: string;
|
category_id: string | null;
|
||||||
amount: number;
|
amount: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
previous_amount: number;
|
previous_amount: number;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
|
import { wayfinder } from '@laravel/vite-plugin-wayfinder';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), wayfinder({ formVariants: true })],
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue