Merge pull request #2 from zdam-egzamin-zawodowy/refactor/rendering-qualifications
Refactor - HomeScreen
This commit is contained in:
commit
2f6ea33d58
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -43,6 +43,7 @@ const Header = ({ onSearch }: HeaderProps) => {
|
|||
value={search}
|
||||
ref={inputRef}
|
||||
allowFontScaling={false}
|
||||
clearButtonMode="always"
|
||||
/>
|
||||
</Item>
|
||||
<View>
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
Reference in New Issue