Merge pull request #2 from zdam-egzamin-zawodowy/refactor/rendering-qualifications

Refactor - HomeScreen
This commit is contained in:
Dawid Wysokiński 2021-05-15 19:21:57 +02:00 committed by GitHub
commit 2f6ea33d58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 328 additions and 211 deletions

View File

@ -1,13 +0,0 @@
/// <reference path="../../../node_modules/native-base/index.d.ts" />
import { FlatListProps } from 'react-native';
declare module 'native-base' {
namespace NativeBase {
interface List {
ListEmptyComponent?: FlatListProps<any>['ListEmptyComponent'];
initialNumToRender?: number;
maxToRenderPerBatch?: number;
}
}
}

View File

@ -1,51 +1,19 @@
import React, { useMemo, useState } from 'react';
import { NetworkStatus, useQuery } from '@apollo/client';
import { useVariables } from 'libs/native-base';
import { Query, QueryProfessionsArgs } from 'libs/graphql';
import { QUERY_PROFESSIONS } from './queries';
import React, { useState } from 'react';
import { Container, Content, Spinner } from 'native-base';
import Professions from './components/Professions/Professions';
import { Container } from 'native-base';
import Header from './components/Header/Header';
import ModeSelector, { Mode } from './components/ModeSelector/ModeSelector';
import NetworkConnectionAlert from './components/NetworkConnectionAlert/NetworkConnectionAlert';
import Professions from './components/Professions/Professions';
const HomeScreen = () => {
const [search, setSearch] = useState('');
const [mode, setMode] = useState(Mode.All);
const variables = useVariables();
const { loading, data, refetch, networkStatus, error } = useQuery<
Pick<Query, 'professions'>,
QueryProfessionsArgs
>(QUERY_PROFESSIONS, {
fetchPolicy: 'cache-and-network',
variables: { sort: ['name ASC'] },
notifyOnNetworkStatusChange: true,
});
const professions = useMemo(() => {
return (data?.professions.items ?? []).filter(
profession => profession.qualifications.length > 0,
);
}, [data]);
return (
<Container>
<Header onSearch={setSearch} />
<ModeSelector mode={mode} onChangeMode={setMode} />
{loading && professions.length === 0 ? (
<Content padder>
<Spinner color={variables.brandPrimary} size="large" />
</Content>
) : (
<Professions
professions={professions}
refreshing={networkStatus === NetworkStatus.refetch}
onRefresh={refetch}
mode={mode}
search={search}
/>
)}
<NetworkConnectionAlert error={error} />
<Professions mode={mode} search={search} />
</Container>
);
};

View File

@ -43,6 +43,7 @@ const Header = ({ onSearch }: HeaderProps) => {
value={search}
ref={inputRef}
allowFontScaling={false}
clearButtonMode="always"
/>
</Item>
<View>

View File

@ -0,0 +1,146 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useUpdateEffect } from 'react-use';
import { useSavedQualifications } from 'libs/savedqualifications';
import { Maybe, Profession, Qualification } from 'libs/graphql';
import { Mode } from '../ModeSelector/ModeSelector';
import {
FlatList,
FlatListProps,
RefreshControl,
StyleSheet,
} from 'react-native';
import { View } from 'native-base';
import ListItem, { ListItemProps } from './ListItem';
import ListEmpty from './ListEmpty';
import QualificationModal from './QualificationModal';
export interface ListProps
extends Pick<FlatListProps<Profession>, 'refreshing' | 'onRefresh'> {
professions: Profession[];
mode: Mode;
search: string;
}
const noop = () => {};
const ID_SEPARATOR = '.';
const getQualificationAndProfessionID = (str: string): [number, number] => {
const [professionID, qualificationID] = str.split(ID_SEPARATOR);
return [parseInt(professionID, 10), parseInt(qualificationID, 10)];
};
const MyList = ({
professions,
refreshing,
onRefresh,
mode,
search,
}: ListProps) => {
const listRef = useRef<any>(null);
const [selectedQualification, setSelectedQualification] = useState<
Maybe<Qualification>
>(null);
const [showModal, setShowModal] = useState(false);
const { isSaved } = useSavedQualifications();
useUpdateEffect(() => {
listRef.current?._root?.scrollToOffset({ offset: 0, animated: false });
}, [mode, search]);
const handlePress = useCallback(
(id: string) => {
const [professionID, qualificationID] = getQualificationAndProfessionID(
id,
);
const profession = professions.find(p => p.id === professionID);
if (profession) {
const qualification = profession.qualifications.find(
q => q.id === qualificationID,
);
if (qualification) {
setSelectedQualification(qualification);
setShowModal(true);
}
}
},
[setShowModal, setSelectedQualification, professions],
);
const renderItem = useCallback(({ item }: { item: ListItemProps }) => {
return <ListItem {...item} />;
}, []);
const keyExtractor = useCallback(item => item.id, []);
const listItems = useMemo(() => {
let items: ListItemProps[] = [];
professions.forEach(profession => {
const qualifications = profession.qualifications
.filter(
qualification =>
(!search ||
qualification.name.toLowerCase().includes(search) ||
qualification.code.toLowerCase().includes(search)) &&
(mode === Mode.All || isSaved(qualification.id)),
)
.map(
(qualification): ListItemProps => {
return {
text: `${qualification.name} (${qualification.code})`,
itemDivider: false,
itemHeader: false,
id: `${profession.id}${ID_SEPARATOR}${qualification.id}`,
onPress: handlePress,
};
},
);
if (qualifications.length > 0) {
items = [
...items,
{
text: profession.name,
itemHeader: true,
itemDivider: true,
id: 'P' + profession.id,
} as ListItemProps,
...qualifications,
];
}
});
return items;
}, [professions, search, mode, isSaved, handlePress]);
return (
<View style={styles.container}>
<FlatList
ref={listRef}
data={listItems}
contentContainerStyle={styles.contentContainer}
renderItem={renderItem}
ListEmptyComponent={<ListEmpty />}
keyExtractor={keyExtractor}
maxToRenderPerBatch={5}
onEndReachedThreshold={0.75}
refreshControl={
<RefreshControl
refreshing={refreshing ?? false}
onRefresh={onRefresh ?? noop}
/>
}
initialNumToRender={10}
/>
<QualificationModal
onPressBackdrop={() => setShowModal(false)}
qualification={selectedQualification}
visible={showModal}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
flexGrow: 1,
},
});
export default MyList;

View File

@ -1,40 +0,0 @@
import React from 'react';
import { Profession, Qualification } from 'libs/graphql';
import { Icon, Left, ListItem, Right, Text, View } from 'native-base';
export interface ItemProps {
profession: Profession;
onPress?: (qualification: Qualification) => void;
}
const Item = ({ profession, onPress }: ItemProps) => {
return (
<View>
<ListItem itemHeader itemDivider>
<Text>{profession.name}</Text>
</ListItem>
{profession.qualifications.map(qualification => (
<ListItem
key={qualification.id}
onPress={() => {
if (onPress) {
onPress(qualification);
}
}}
>
<Left>
<Text>
{qualification.name} ({qualification.code})
</Text>
</Left>
<Right>
<Icon name="arrow-forward" />
</Right>
</ListItem>
))}
</View>
);
};
export default React.memo(Item);

View File

@ -0,0 +1,65 @@
import React, { forwardRef, useCallback } from 'react';
import {
FlatList,
FlatListProps,
RefreshControl,
StyleSheet,
} from 'react-native';
import ListItem, { ListItemProps } from './ListItem';
import ListEmpty from './ListEmpty';
import ListLoading from './ListLoading';
export type Item = ListItemProps;
export interface ListProps
extends Pick<
FlatListProps<Item>,
'refreshing' | 'onRefresh' | 'contentContainerStyle'
> {
items: Item[];
loading?: boolean;
}
const noop = () => {};
const MyList = forwardRef<FlatList<Item>, ListProps>(
({ items, refreshing, onRefresh, loading, ...rest }: ListProps, ref) => {
const renderItem = useCallback(({ item }: { item: Item }) => {
return <ListItem {...item} />;
}, []);
const keyExtractor = useCallback(item => item.id, []);
console.log('render');
return (
<FlatList
data={items}
ref={ref}
renderItem={renderItem}
ListEmptyComponent={loading ? null : <ListEmpty />}
ListFooterComponent={loading ? <ListLoading /> : null}
ListFooterComponentStyle={styles.footerWrapper}
keyExtractor={keyExtractor}
maxToRenderPerBatch={5}
onEndReachedThreshold={0.75}
refreshControl={
<RefreshControl
refreshing={refreshing ?? false}
onRefresh={onRefresh ?? noop}
/>
}
initialNumToRender={10}
{...rest}
/>
);
},
);
const styles = StyleSheet.create({
footerWrapper: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
export default MyList;

View File

@ -1,12 +1,12 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { H1, Content } from 'native-base';
import { H1, View } from 'native-base';
const ListEmpty = () => {
return (
<Content padder contentContainerStyle={styles.wrapper}>
<View padder style={styles.wrapper}>
<H1 style={styles.heading}>Nie znaleziono żadnej kwalifikacji</H1>
</Content>
</View>
);
};

View File

@ -0,0 +1,46 @@
import React, { Fragment, useCallback } from 'react';
import { Icon, Left, ListItem, NativeBase, Right, Text } from 'native-base';
export interface ListItemProps
extends Pick<NativeBase.ListItem, 'itemDivider' | 'itemHeader'> {
onPress?: (id: string) => void;
id?: string;
text: string;
}
const MyListItem = ({
onPress,
text,
itemDivider,
itemHeader,
id,
}: ListItemProps) => {
const handlePress = useCallback(() => {
if (onPress && id) {
onPress(id);
}
}, [onPress, id]);
return (
<ListItem
itemHeader={itemHeader}
itemDivider={itemDivider}
onPress={handlePress}
>
{onPress ? (
<Fragment>
<Left>
<Text>{text}</Text>
</Left>
<Right>
<Icon name="arrow-forward" />
</Right>
</Fragment>
) : (
<Text>{text}</Text>
)}
</ListItem>
);
};
export default React.memo(MyListItem);

View File

@ -0,0 +1,10 @@
import React from 'react';
import { Spinner } from 'native-base';
import { useVariables } from 'libs/native-base';
const ListLoading = () => {
const variables = useVariables();
return <Spinner size="large" color={variables.brandPrimary} />;
};
export default ListLoading;

View File

@ -1,114 +0,0 @@
import React, { useCallback, useRef, useState } from 'react';
import { useUpdateEffect } from 'react-use';
import { useSavedQualifications } from 'libs/savedqualifications';
import { Maybe, Profession, Qualification } from 'libs/graphql';
import { Mode } from '../ModeSelector/ModeSelector';
import { FlatListProps, RefreshControl, StyleSheet } from 'react-native';
import { List, View } from 'native-base';
import Item from './Item';
import QualificationModal from './QualificationModal';
import ListEmpty from './ListEmpty';
export interface ProfessionsProps
extends Pick<FlatListProps<Profession>, 'refreshing' | 'onRefresh'> {
professions: Profession[];
mode: Mode;
search: string;
}
const noop = () => {};
const Professions = ({
professions,
refreshing,
onRefresh,
mode,
search,
}: ProfessionsProps) => {
const listRef = useRef<any>(null);
const { isSaved } = useSavedQualifications();
const [filteredProfessions, setFilteredProfessions] = useState<
Maybe<Profession[]>
>(null);
const [selectedQualification, setSelectedQualification] = useState<
Maybe<Qualification>
>(null);
const [showModal, setShowModal] = useState(false);
useUpdateEffect(() => {
listRef.current?._root?.scrollToOffset({ offset: 0, animated: false });
}, [mode]);
useUpdateEffect(() => {
if (!search && mode === Mode.All && filteredProfessions) {
setFilteredProfessions(null);
return;
}
const newFilteredProfessions: Profession[] = [];
professions.forEach(profession => {
const qualifications = profession.qualifications.filter(
qualification =>
(!search ||
qualification.name.toLowerCase().includes(search) ||
qualification.code.toLowerCase().includes(search)) &&
(mode === Mode.All || isSaved(qualification.id)),
);
if (qualifications.length > 0) {
newFilteredProfessions.push({ ...profession, qualifications });
}
});
setFilteredProfessions(newFilteredProfessions);
}, [professions, search, mode, isSaved]);
const handlePress = useCallback(
(qualification: Qualification) => {
setSelectedQualification(qualification);
setShowModal(true);
},
[setShowModal, setSelectedQualification],
);
const renderItem = useCallback(
({ item }: { item: Profession }) => {
return <Item profession={item} onPress={handlePress} />;
},
[handlePress],
);
const keyExtractor = useCallback(item => item.id, []);
return (
<View style={styles.container}>
<List
ref={listRef}
dataArray={filteredProfessions ?? professions}
contentContainerStyle={styles.contentContainer}
renderItem={renderItem}
ListEmptyComponent={<ListEmpty />}
keyExtractor={keyExtractor}
maxToRenderPerBatch={5}
refreshControl={
<RefreshControl
refreshing={refreshing ?? false}
onRefresh={onRefresh ?? noop}
/>
}
initialNumToRender={5}
/>
<QualificationModal
onPressBackdrop={() => setShowModal(false)}
qualification={selectedQualification}
visible={showModal}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
flexGrow: 1,
},
});
export default Professions;

View File

@ -49,7 +49,7 @@ const QualificationModal = ({
<Card style={styles.card}>
<ScrollView pointerEvents={'box-none'}>
<CardItem header bordered style={styles.cardHeader}>
<Body style={{ flex: 3, alignSelf: 'center' }}>
<Body style={styles.body}>
<Text>
{qualification?.name} ({qualification?.code})
</Text>
@ -67,7 +67,7 @@ const QualificationModal = ({
<Icon
type="Entypo"
name={isSaved ? 'star' : 'star-outlined'}
style={{ fontSize: 30 }}
style={styles.icon}
/>
</Button>
</Right>
@ -133,6 +133,8 @@ const styles = StyleSheet.create({
star: {
flex: 1,
},
body: { flex: 3, alignSelf: 'center' },
icon: { fontSize: 30 },
});
export default QualificationModal;

View File

@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { NetworkStatus, useQuery } from '@apollo/client';
import { Query, QueryProfessionsArgs } from 'libs/graphql';
import { QUERY_PROFESSIONS } from './queries';
const useProfessions = () => {
const { loading, data, refetch, networkStatus, error } = useQuery<
Pick<Query, 'professions'>,
QueryProfessionsArgs
>(QUERY_PROFESSIONS, {
fetchPolicy: 'cache-and-network',
variables: { sort: ['name ASC'] },
notifyOnNetworkStatusChange: true,
});
const professions = useMemo(() => {
return (data?.professions.items ?? []).filter(
profession => profession.qualifications.length > 0,
);
}, [data]);
return {
loading: professions.length === 0 && loading,
refetch,
professions,
refetching: networkStatus === NetworkStatus.refetch,
error,
};
};
export default useProfessions;

View File

@ -0,0 +1,17 @@
import { useRef } from 'react';
import { FlatList } from 'react-native';
import { Item } from './List/List';
import { useUpdateEffect } from 'react-use';
import { Mode } from '../ModeSelector/ModeSelector';
const useScrollTopOnSearchOrModeChange = (search: string, mode: Mode) => {
const listRef = useRef<FlatList<Item>>(null);
useUpdateEffect(() => {
listRef.current?.scrollToOffset({ offset: 0, animated: false });
}, [search, mode]);
return listRef;
};
export default useScrollTopOnSearchOrModeChange;

View File

@ -14,9 +14,7 @@ const Stack = createStackNavigator<AppStackParamList>();
const AppStack = createStackNavigator<AppStackParamList>();
const AppScreens = () => (
<AppStack.Navigator
screenOptions={{ animationEnabled: false, headerShown: false }}
>
<AppStack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name={Screen.Home} component={HomeScreen} />
<Stack.Screen name={Screen.Test} component={TestScreen} />
</AppStack.Navigator>