fix: Prevent Novel Genre Rendering Crashes
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
@@ -19,6 +19,7 @@ import { NovelInfo } from '@database/types';
|
||||
import { NovelStatus } from '@plugins/types';
|
||||
import { translateNovelStatus } from '@utils/translateEnum';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { parseGenres } from '../utils/genres';
|
||||
|
||||
interface EditInfoModalProps {
|
||||
theme: ThemeColors;
|
||||
@@ -55,12 +56,15 @@ const EditInfoModalContent = ({
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
const genres = useMemo(
|
||||
() => parseGenres(novelInfo.genres),
|
||||
[novelInfo.genres],
|
||||
);
|
||||
|
||||
const removeTag = (t: string) => {
|
||||
setNovelInfo(current => ({
|
||||
...current,
|
||||
genres: current.genres
|
||||
?.split(',')
|
||||
genres: parseGenres(current.genres)
|
||||
.filter(item => item !== t)
|
||||
.join(','),
|
||||
}));
|
||||
@@ -199,9 +203,9 @@ const EditInfoModalContent = ({
|
||||
|
||||
setNovelInfo(prevVal => ({
|
||||
...prevVal,
|
||||
genres: prevVal.genres
|
||||
? `${prevVal.genres},` + newGenreTrimmed
|
||||
: newGenreTrimmed,
|
||||
genres: [...parseGenres(prevVal.genres), newGenreTrimmed].join(
|
||||
',',
|
||||
),
|
||||
}));
|
||||
setNewGenre('');
|
||||
}}
|
||||
@@ -210,11 +214,11 @@ const EditInfoModalContent = ({
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
|
||||
{novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
|
||||
{genres.length > 0 ? (
|
||||
<FlatList
|
||||
style={getGenreListStyle()}
|
||||
horizontal
|
||||
data={novelInfo.genres?.split(',')}
|
||||
data={genres}
|
||||
keyExtractor={(_, index) => 'novelTag' + index}
|
||||
renderItem={({ item }) => (
|
||||
<GenreChip theme={theme} onPress={() => removeTag(item)}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isMissingNovelCover } from '../../../../components/NovelCoverImage';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { getString } from '@i18n/translations';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { parseGenres } from '../../utils/genres';
|
||||
|
||||
interface CoverImageProps {
|
||||
children: React.ReactNode;
|
||||
@@ -269,26 +270,29 @@ const TrackerButton = ({
|
||||
|
||||
const genreKeyExtractor = (_item: string, index: number) => 'genre' + index;
|
||||
|
||||
const NovelGenres = memo(
|
||||
({ theme, genres }: { theme: ThemeColors; genres: string }) => {
|
||||
const data = useMemo(() => genres.split(/,\s*/), [genres]);
|
||||
const renderGenre = useCallback(
|
||||
({ item }: { item: string }) => <Chip label={item} theme={theme} />,
|
||||
[theme],
|
||||
);
|
||||
interface NovelGenresProps {
|
||||
theme: ThemeColors;
|
||||
genres?: string | null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentContainerStyle={styles.genreContainer}
|
||||
horizontal
|
||||
data={data}
|
||||
keyExtractor={genreKeyExtractor}
|
||||
renderItem={renderGenre}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
const NovelGenres = memo(({ theme, genres }: NovelGenresProps) => {
|
||||
const data = useMemo(() => parseGenres(genres), [genres]);
|
||||
const renderGenre = useCallback(
|
||||
({ item }: { item: string }) => <Chip label={item} theme={theme} />,
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentContainerStyle={styles.genreContainer}
|
||||
horizontal
|
||||
data={data}
|
||||
keyExtractor={genreKeyExtractor}
|
||||
renderItem={renderGenre}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export {
|
||||
NovelInfoContainer,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react-native';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
import { NovelGenres } from '../NovelInfoComponents';
|
||||
|
||||
jest.mock('../../../../../components', () => {
|
||||
const { Text } = require('react-native');
|
||||
|
||||
return {
|
||||
Chip: ({ label }: { label: string }) => <Text>{label}</Text>,
|
||||
NovelCoverImage: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
const theme = {} as ThemeColors;
|
||||
|
||||
describe('NovelGenres', () => {
|
||||
it('renders normalized genres', () => {
|
||||
render(
|
||||
<NovelGenres theme={theme} genres="Fantasy, Adventure, , Romance " />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Fantasy')).toBeTruthy();
|
||||
expect(screen.getByText('Adventure')).toBeTruthy();
|
||||
expect(screen.getByText('Romance')).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['null', null],
|
||||
['malformed', ['Fantasy'] as unknown as string],
|
||||
])('does not crash for %s genres', (_case, genres) => {
|
||||
expect(() =>
|
||||
render(<NovelGenres theme={theme} genres={genres} />),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { parseGenres } from '../genres';
|
||||
|
||||
describe('parseGenres', () => {
|
||||
it('normalizes comma-separated genres', () => {
|
||||
expect(parseGenres('Fantasy, Adventure, , Romance ')).toEqual([
|
||||
'Fantasy',
|
||||
'Adventure',
|
||||
'Romance',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([undefined, null, ['Fantasy'], { genre: 'Fantasy' }])(
|
||||
'returns an empty list for malformed input: %p',
|
||||
genres => {
|
||||
expect(parseGenres(genres)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export const parseGenres = (genres: unknown): string[] =>
|
||||
typeof genres === 'string'
|
||||
? genres
|
||||
.split(',')
|
||||
.map(genre => genre.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
Reference in New Issue
Block a user