Fix NaN rendering error in About section from Directus CMS data (#62)
* Initial plan * Fix NaN error in gaming and custom activities rendering Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com> * Add unit tests for NaN handling in ActivityFeed Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com> * Refactor: Extract gaming text logic to helper function Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com> * Address code review feedback: improve types and tests Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com> * Fix NaN error in About component (tech stack and hobbies) Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com> * Update TypeScript interfaces to match actual data types Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com> * Improve NaN handling with defensive logging and better null checks Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
This commit is contained in:
150
app/__tests__/components/ActivityFeed.test.tsx
Normal file
150
app/__tests__/components/ActivityFeed.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
/**
|
||||
* Unit tests for ActivityFeed NaN handling
|
||||
*
|
||||
* This test suite validates that the ActivityFeed component correctly handles
|
||||
* NaN and numeric values in gaming and custom activity data to prevent
|
||||
* "Received NaN for the children attribute" React errors.
|
||||
*/
|
||||
describe('ActivityFeed NaN Handling', () => {
|
||||
describe('Gaming activity rendering logic', () => {
|
||||
// Helper function to simulate getSafeGamingText behavior
|
||||
const getSafeGamingText = (details: string | number | undefined, state: string | number | undefined, fallback: string): string => {
|
||||
if (typeof details === 'string' && details.trim().length > 0) return details;
|
||||
if (typeof state === 'string' && state.trim().length > 0) return state;
|
||||
if (typeof details === 'number' && !isNaN(details)) return String(details);
|
||||
if (typeof state === 'number' && !isNaN(state)) return String(state);
|
||||
return fallback;
|
||||
};
|
||||
|
||||
it('should safely handle NaN in gaming.details', () => {
|
||||
const result = getSafeGamingText(NaN, 'Playing', 'Playing...');
|
||||
expect(result).toBe('Playing'); // Should fall through NaN to state
|
||||
expect(result).not.toBe(NaN);
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should safely handle NaN in both gaming.details and gaming.state', () => {
|
||||
const result = getSafeGamingText(NaN, NaN, 'Playing...');
|
||||
expect(result).toBe('Playing...'); // Should use fallback
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should prioritize string details over numeric state', () => {
|
||||
const result = getSafeGamingText('Details text', 42, 'Playing...');
|
||||
expect(result).toBe('Details text'); // String details takes precedence
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should prioritize string state over numeric details', () => {
|
||||
const result = getSafeGamingText(42, 'State text', 'Playing...');
|
||||
expect(result).toBe('State text'); // String state takes precedence over numeric details
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should convert valid numeric details to string', () => {
|
||||
const result = getSafeGamingText(42, undefined, 'Playing...');
|
||||
expect(result).toBe('42');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle empty strings correctly', () => {
|
||||
const result1 = getSafeGamingText('', 'Playing', 'Playing...');
|
||||
expect(result1).toBe('Playing'); // Empty string should fall through to state
|
||||
|
||||
const result2 = getSafeGamingText(' ', 'Playing', 'Playing...');
|
||||
expect(result2).toBe('Playing'); // Whitespace-only should fall through to state
|
||||
});
|
||||
|
||||
it('should convert gaming.name to string safely', () => {
|
||||
const validName = String('Test Game' || '');
|
||||
expect(validName).toBe('Test Game');
|
||||
expect(typeof validName).toBe('string');
|
||||
|
||||
// In the actual code, we use String(data.gaming.name || '')
|
||||
// If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy
|
||||
const nanName = String(NaN || '');
|
||||
expect(nanName).toBe(''); // NaN is falsy, so it falls back to ''
|
||||
expect(typeof nanName).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom activities progress handling', () => {
|
||||
it('should only render progress bar when progress is a valid number', () => {
|
||||
const validProgress = 75;
|
||||
const shouldRender = validProgress !== undefined &&
|
||||
typeof validProgress === 'number' &&
|
||||
!isNaN(validProgress);
|
||||
expect(shouldRender).toBe(true);
|
||||
});
|
||||
|
||||
it('should not render progress bar when progress is NaN', () => {
|
||||
const invalidProgress = NaN;
|
||||
const shouldRender = invalidProgress !== undefined &&
|
||||
typeof invalidProgress === 'number' &&
|
||||
!isNaN(invalidProgress);
|
||||
expect(shouldRender).toBe(false);
|
||||
});
|
||||
|
||||
it('should not render progress bar when progress is undefined', () => {
|
||||
const undefinedProgress = undefined;
|
||||
const shouldRender = undefinedProgress !== undefined &&
|
||||
typeof undefinedProgress === 'number' &&
|
||||
!isNaN(undefinedProgress);
|
||||
expect(shouldRender).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom activities dynamic field rendering', () => {
|
||||
it('should safely convert valid numeric values to string', () => {
|
||||
const value = 42;
|
||||
const shouldRender = typeof value === 'string' ||
|
||||
(typeof value === 'number' && !isNaN(value));
|
||||
|
||||
expect(shouldRender).toBe(true);
|
||||
|
||||
if (shouldRender) {
|
||||
const stringValue = String(value);
|
||||
expect(stringValue).toBe('42');
|
||||
expect(typeof stringValue).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not render NaN values', () => {
|
||||
const value = NaN;
|
||||
const shouldRender = typeof value === 'string' ||
|
||||
(typeof value === 'number' && !isNaN(value));
|
||||
|
||||
expect(shouldRender).toBe(false);
|
||||
});
|
||||
|
||||
it('should render valid string values', () => {
|
||||
const value = 'Test String';
|
||||
const shouldRender = typeof value === 'string' ||
|
||||
(typeof value === 'number' && !isNaN(value));
|
||||
|
||||
expect(shouldRender).toBe(true);
|
||||
|
||||
if (shouldRender) {
|
||||
const stringValue = String(value);
|
||||
expect(stringValue).toBe('Test String');
|
||||
expect(typeof stringValue).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('should render zero as a valid numeric value', () => {
|
||||
const value = 0;
|
||||
const shouldRender = typeof value === 'string' ||
|
||||
(typeof value === 'number' && !isNaN(value));
|
||||
|
||||
expect(shouldRender).toBe(true);
|
||||
|
||||
if (shouldRender) {
|
||||
const stringValue = String(value);
|
||||
expect(stringValue).toBe('0');
|
||||
expect(typeof stringValue).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import CurrentlyReading from "./CurrentlyReading";
|
||||
// Type definitions for CMS data
|
||||
interface TechStackItem {
|
||||
id: string;
|
||||
name: string;
|
||||
name: string | number | null | undefined;
|
||||
url?: string;
|
||||
icon_url?: string;
|
||||
sort: number;
|
||||
@@ -30,7 +30,7 @@ interface Hobby {
|
||||
id: string;
|
||||
key: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
title: string | number | null | undefined;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -172,20 +172,59 @@ const About = () => {
|
||||
|
||||
// Use CMS Hobbies if available, otherwise fallback
|
||||
const hobbies = hobbiesFromCMS
|
||||
? hobbiesFromCMS.map((hobby: Hobby) => ({
|
||||
icon: iconMap[hobby.icon] || Code,
|
||||
text: hobby.title
|
||||
}))
|
||||
? hobbiesFromCMS
|
||||
.map((hobby: Hobby) => {
|
||||
// Convert to string, handling NaN/null/undefined
|
||||
const text = hobby.title == null || (typeof hobby.title === 'number' && isNaN(hobby.title))
|
||||
? ''
|
||||
: String(hobby.title);
|
||||
return {
|
||||
icon: iconMap[hobby.icon] || Code,
|
||||
text
|
||||
};
|
||||
})
|
||||
.filter(h => {
|
||||
const isValid = h.text.trim().length > 0;
|
||||
if (!isValid && process.env.NODE_ENV === 'development') {
|
||||
console.log('[About] Filtered out invalid hobby:', h);
|
||||
}
|
||||
return isValid;
|
||||
})
|
||||
: hobbiesFallback;
|
||||
|
||||
// Use CMS Tech Stack if available, otherwise fallback
|
||||
const techStack = techStackFromCMS
|
||||
? techStackFromCMS.map((cat: TechStackCategory) => ({
|
||||
key: cat.key,
|
||||
category: cat.name,
|
||||
icon: iconMap[cat.icon] || Code,
|
||||
items: cat.items.map((item: TechStackItem) => item.name)
|
||||
}))
|
||||
? techStackFromCMS.map((cat: TechStackCategory) => {
|
||||
const items = cat.items
|
||||
.map((item: TechStackItem) => {
|
||||
// Convert to string, handling NaN/null/undefined
|
||||
if (item.name == null || (typeof item.name === 'number' && isNaN(item.name))) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[About] Invalid item.name in category', cat.key, ':', item);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return String(item.name);
|
||||
})
|
||||
.filter(name => {
|
||||
const isValid = name.trim().length > 0;
|
||||
if (!isValid && process.env.NODE_ENV === 'development') {
|
||||
console.log('[About] Filtered out empty item name in category', cat.key);
|
||||
}
|
||||
return isValid;
|
||||
});
|
||||
|
||||
if (items.length === 0 && process.env.NODE_ENV === 'development') {
|
||||
console.warn('[About] Category has no valid items after filtering:', cat.key);
|
||||
}
|
||||
|
||||
return {
|
||||
key: cat.key,
|
||||
category: cat.name,
|
||||
icon: iconMap[cat.icon] || Code,
|
||||
items
|
||||
};
|
||||
})
|
||||
: techStackFallback;
|
||||
|
||||
return (
|
||||
@@ -297,7 +336,7 @@ const About = () => {
|
||||
: "bg-liquid-sky/10 border-liquid-sky/30 hover:bg-liquid-sky/20 hover:border-liquid-sky/50"
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
{String(item)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -336,7 +375,7 @@ const About = () => {
|
||||
>
|
||||
<hobby.icon size={20} className="text-stone-600" />
|
||||
<span className="text-stone-700 font-medium">
|
||||
{hobby.text}
|
||||
{String(hobby.text)}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
@@ -37,8 +37,8 @@ interface StatusData {
|
||||
isPlaying: boolean;
|
||||
name: string;
|
||||
image: string | null;
|
||||
state?: string;
|
||||
details?: string;
|
||||
state?: string | number;
|
||||
details?: string | number;
|
||||
} | null;
|
||||
coding: {
|
||||
isActive: boolean;
|
||||
@@ -54,6 +54,20 @@ interface StatusData {
|
||||
customActivities?: Record<string, CustomActivity>; // Dynamisch!
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely converts gaming activity details/state to a string, filtering out NaN values.
|
||||
* Priority: string details > string state > numeric details > numeric state > fallback
|
||||
*/
|
||||
function getSafeGamingText(details: string | number | undefined, state: string | number | undefined, fallback: string): string {
|
||||
// Check string values first (maintain original precedence)
|
||||
if (typeof details === 'string' && details.trim().length > 0) return details;
|
||||
if (typeof state === 'string' && state.trim().length > 0) return state;
|
||||
// Check numeric values, but filter out NaN
|
||||
if (typeof details === 'number' && !isNaN(details)) return String(details);
|
||||
if (typeof state === 'number' && !isNaN(state)) return String(state);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function ActivityFeed() {
|
||||
const [data, setData] = useState<StatusData | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
@@ -1803,12 +1817,10 @@ export default function ActivityFeed() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-bold text-sm text-white truncate mb-0.5">
|
||||
{data.gaming.name}
|
||||
{String(data.gaming.name || "")}
|
||||
</p>
|
||||
<p className="text-xs text-indigo-200/60 truncate">
|
||||
{data.gaming.details ||
|
||||
data.gaming.state ||
|
||||
"Playing..."}
|
||||
{getSafeGamingText(data.gaming.details, data.gaming.state, "Playing...")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1967,7 +1979,7 @@ export default function ActivityFeed() {
|
||||
)}
|
||||
|
||||
{/* Progress Bar wenn vorhanden */}
|
||||
{activity.progress !== undefined && typeof activity.progress === 'number' && (
|
||||
{activity.progress !== undefined && typeof activity.progress === 'number' && !isNaN(activity.progress) && (
|
||||
<div className="mt-1.5">
|
||||
<div className="h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
@@ -1990,12 +2002,12 @@ export default function ActivityFeed() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Nur einfache Werte rendern
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
// Nur einfache Werte rendern, aber NaN ausfiltern
|
||||
if (typeof value === 'string' || (typeof value === 'number' && !isNaN(value))) {
|
||||
return (
|
||||
<div key={key} className="text-[10px] text-white/50 mt-0.5">
|
||||
<span className="capitalize">{key.replace(/_/g, ' ')}: </span>
|
||||
<span className="font-medium">{value}</span>
|
||||
<span className="font-medium">{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user