Improve Clubs components to match linter

This commit is contained in:
Arnaud Vergnet 2020-08-03 21:53:53 +02:00
parent 33d98b024b
commit 93d12b27f8
6 changed files with 705 additions and 627 deletions

View file

@ -2,82 +2,90 @@
import * as React from 'react'; import * as React from 'react';
import {Card, Chip, List, Text} from 'react-native-paper'; import {Card, Chip, List, Text} from 'react-native-paper';
import {StyleSheet, View} from "react-native"; import {StyleSheet, View} from 'react-native';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import AnimatedAccordion from "../../Animations/AnimatedAccordion"; import AnimatedAccordion from '../../Animations/AnimatedAccordion';
import {isItemInCategoryFilter} from "../../../utils/Search"; import {isItemInCategoryFilter} from '../../../utils/Search';
import type {category} from "../../../screens/Amicale/Clubs/ClubListScreen"; import type {ClubCategoryType} from '../../../screens/Amicale/Clubs/ClubListScreen';
type Props = { type PropsType = {
categories: Array<category>, categories: Array<ClubCategoryType>,
onChipSelect: (id: number) => void, onChipSelect: (id: number) => void,
selectedCategories: Array<number>, selectedCategories: Array<number>,
} };
class ClubListHeader extends React.Component<Props> {
shouldComponentUpdate(nextProps: Props) {
return nextProps.selectedCategories.length !== this.props.selectedCategories.length;
}
getChipRender = (category: category, key: string) => {
const onPress = () => this.props.onChipSelect(category.id);
return <Chip
selected={isItemInCategoryFilter(this.props.selectedCategories, [category.id])}
mode={'outlined'}
onPress={onPress}
style={{marginRight: 5, marginLeft: 5, marginBottom: 5}}
key={key}
>
{category.name}
</Chip>;
};
getCategoriesRender() {
let final = [];
for (let i = 0; i < this.props.categories.length; i++) {
final.push(this.getChipRender(this.props.categories[i], this.props.categories[i].id.toString()));
}
return final;
}
render() {
return (
<Card style={styles.card}>
<AnimatedAccordion
title={i18n.t("screens.clubs.categories")}
left={props => <List.Icon {...props} icon="star"/>}
opened={true}
>
<Text style={styles.text}>{i18n.t("screens.clubs.categoriesFilterMessage")}</Text>
<View style={styles.chipContainer}>
{this.getCategoriesRender()}
</View>
</AnimatedAccordion>
</Card>
);
}
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
margin: 5 margin: 5,
}, },
text: { text: {
paddingLeft: 0, paddingLeft: 0,
marginTop: 5, marginTop: 5,
marginBottom: 10, marginBottom: 10,
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
}, },
chipContainer: { chipContainer: {
justifyContent: 'space-around', justifyContent: 'space-around',
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
paddingLeft: 0, paddingLeft: 0,
marginBottom: 5, marginBottom: 5,
}, },
}); });
class ClubListHeader extends React.Component<PropsType> {
shouldComponentUpdate(nextProps: PropsType): boolean {
const {props} = this;
return (
nextProps.selectedCategories.length !== props.selectedCategories.length
);
}
getChipRender = (category: ClubCategoryType, key: string): React.Node => {
const {props} = this;
const onPress = (): void => props.onChipSelect(category.id);
return (
<Chip
selected={isItemInCategoryFilter(props.selectedCategories, [
category.id,
null,
])}
mode="outlined"
onPress={onPress}
style={{marginRight: 5, marginLeft: 5, marginBottom: 5}}
key={key}>
{category.name}
</Chip>
);
};
getCategoriesRender(): React.Node {
const {props} = this;
const final = [];
props.categories.forEach((cat: ClubCategoryType) => {
final.push(this.getChipRender(cat, cat.id.toString()));
});
return final;
}
render(): React.Node {
return (
<Card style={styles.card}>
<AnimatedAccordion
title={i18n.t('screens.clubs.categories')}
left={({size}: {size: number}): React.Node => (
<List.Icon size={size} icon="star" />
)}
opened>
<Text style={styles.text}>
{i18n.t('screens.clubs.categoriesFilterMessage')}
</Text>
<View style={styles.chipContainer}>{this.getCategoriesRender()}</View>
</AnimatedAccordion>
</Card>
);
}
}
export default ClubListHeader; export default ClubListHeader;

View file

@ -2,84 +2,93 @@
import * as React from 'react'; import * as React from 'react';
import {Avatar, Chip, List, withTheme} from 'react-native-paper'; import {Avatar, Chip, List, withTheme} from 'react-native-paper';
import {View} from "react-native"; import {View} from 'react-native';
import type {category, club} from "../../../screens/Amicale/Clubs/ClubListScreen"; import type {
import type {CustomTheme} from "../../../managers/ThemeManager"; ClubCategoryType,
ClubType,
} from '../../../screens/Amicale/Clubs/ClubListScreen';
import type {CustomTheme} from '../../../managers/ThemeManager';
type Props = { type PropsType = {
onPress: () => void, onPress: () => void,
categoryTranslator: (id: number) => category, categoryTranslator: (id: number) => ClubCategoryType,
item: club, item: ClubType,
height: number, height: number,
theme: CustomTheme, theme: CustomTheme,
} };
class ClubListItem extends React.Component<Props> { class ClubListItem extends React.Component<PropsType> {
hasManagers: boolean;
hasManagers: boolean; constructor(props: PropsType) {
super(props);
this.hasManagers = props.item.responsibles.length > 0;
}
constructor(props) { shouldComponentUpdate(): boolean {
super(props); return false;
this.hasManagers = props.item.responsibles.length > 0; }
}
shouldComponentUpdate() { getCategoriesRender(categories: Array<number | null>): React.Node {
return false; const {props} = this;
} const final = [];
categories.forEach((cat: number | null) => {
getCategoriesRender(categories: Array<number | null>) { if (cat != null) {
let final = []; const category: ClubCategoryType = props.categoryTranslator(cat);
for (let i = 0; i < categories.length; i++) { final.push(
if (categories[i] !== null) { <Chip
const category: category = this.props.categoryTranslator(categories[i]); style={{marginRight: 5, marginBottom: 5}}
final.push( key={`${props.item.id}:${category.id}`}>
<Chip {category.name}
style={{marginRight: 5, marginBottom: 5}} </Chip>,
key={this.props.item.id + ':' + category.id}
>
{category.name}
</Chip>
);
}
}
return <View style={{flexDirection: 'row'}}>{final}</View>;
}
render() {
const categoriesRender = this.getCategoriesRender.bind(this, this.props.item.category);
const colors = this.props.theme.colors;
return (
<List.Item
title={this.props.item.name}
description={categoriesRender}
onPress={this.props.onPress}
left={(props) => <Avatar.Image
{...props}
style={{
backgroundColor: 'transparent',
marginLeft: 10,
marginRight: 10,
}}
size={64}
source={{uri: this.props.item.logo}}/>}
right={(props) => <Avatar.Icon
{...props}
style={{
marginTop: 'auto',
marginBottom: 'auto',
backgroundColor: 'transparent',
}}
size={48}
icon={this.hasManagers ? "check-circle-outline" : "alert-circle-outline"}
color={this.hasManagers ? colors.success : colors.primary}
/>}
style={{
height: this.props.height,
justifyContent: 'center',
}}
/>
); );
} }
});
return <View style={{flexDirection: 'row'}}>{final}</View>;
}
render(): React.Node {
const {props} = this;
const categoriesRender = (): React.Node =>
this.getCategoriesRender(props.item.category);
const {colors} = props.theme;
return (
<List.Item
title={props.item.name}
description={categoriesRender}
onPress={props.onPress}
left={(): React.Node => (
<Avatar.Image
style={{
backgroundColor: 'transparent',
marginLeft: 10,
marginRight: 10,
}}
size={64}
source={{uri: props.item.logo}}
/>
)}
right={(): React.Node => (
<Avatar.Icon
style={{
marginTop: 'auto',
marginBottom: 'auto',
backgroundColor: 'transparent',
}}
size={48}
icon={
this.hasManagers ? 'check-circle-outline' : 'alert-circle-outline'
}
color={this.hasManagers ? colors.success : colors.primary}
/>
)}
style={{
height: props.height,
justifyContent: 'center',
}}
/>
);
}
} }
export default withTheme(ClubListItem); export default withTheme(ClubListItem);

View file

@ -4,49 +4,49 @@ import * as React from 'react';
import {Image, View} from 'react-native'; import {Image, View} from 'react-native';
import {Card, List, Text, withTheme} from 'react-native-paper'; import {Card, List, Text, withTheme} from 'react-native-paper';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import Autolink from "react-native-autolink"; import Autolink from 'react-native-autolink';
import CollapsibleScrollView from "../../../components/Collapsible/CollapsibleScrollView"; import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView';
import AMICALE_ICON from '../../../../assets/amicale.png';
type Props = {};
const CONTACT_LINK = 'clubs@amicale-insat.fr'; const CONTACT_LINK = 'clubs@amicale-insat.fr';
class ClubAboutScreen extends React.Component<Props> { // eslint-disable-next-line react/prefer-stateless-function
class ClubAboutScreen extends React.Component<null> {
render() { render(): React.Node {
return ( return (
<CollapsibleScrollView style={{padding: 5}}> <CollapsibleScrollView style={{padding: 5}}>
<View style={{ <View
width: '100%', style={{
height: 100, width: '100%',
marginTop: 20, height: 100,
marginBottom: 20, marginTop: 20,
justifyContent: 'center', marginBottom: 20,
alignItems: 'center' justifyContent: 'center',
}}> alignItems: 'center',
<Image }}>
source={require('../../../../assets/amicale.png')} <Image
style={{flex: 1, resizeMode: "contain"}} source={AMICALE_ICON}
resizeMode="contain"/> style={{flex: 1, resizeMode: 'contain'}}
</View> resizeMode="contain"
<Text>{i18n.t("screens.clubs.about.text")}</Text> />
<Card style={{margin: 5}}> </View>
<Card.Title <Text>{i18n.t('screens.clubs.about.text')}</Text>
title={i18n.t("screens.clubs.about.title")} <Card style={{margin: 5}}>
subtitle={i18n.t("screens.clubs.about.subtitle")} <Card.Title
left={props => <List.Icon {...props} icon={'information'}/>} title={i18n.t('screens.clubs.about.title')}
/> subtitle={i18n.t('screens.clubs.about.subtitle')}
<Card.Content> left={({size}: {size: number}): React.Node => (
<Text>{i18n.t("screens.clubs.about.message")}</Text> <List.Icon size={size} icon="information" />
<Autolink )}
text={CONTACT_LINK} />
component={Text} <Card.Content>
/> <Text>{i18n.t('screens.clubs.about.message')}</Text>
</Card.Content> <Autolink text={CONTACT_LINK} component={Text} />
</Card> </Card.Content>
</CollapsibleScrollView> </Card>
); </CollapsibleScrollView>
} );
}
} }
export default withTheme(ClubAboutScreen); export default withTheme(ClubAboutScreen);

View file

@ -2,252 +2,276 @@
import * as React from 'react'; import * as React from 'react';
import {Linking, View} from 'react-native'; import {Linking, View} from 'react-native';
import {Avatar, Button, Card, Chip, Paragraph, withTheme} from 'react-native-paper'; import {
Avatar,
Button,
Card,
Chip,
Paragraph,
withTheme,
} from 'react-native-paper';
import ImageModal from 'react-native-image-modal'; import ImageModal from 'react-native-image-modal';
import i18n from "i18n-js"; import i18n from 'i18n-js';
import AuthenticatedScreen from "../../../components/Amicale/AuthenticatedScreen"; import {StackNavigationProp} from '@react-navigation/stack';
import CustomHTML from "../../../components/Overrides/CustomHTML"; import AuthenticatedScreen from '../../../components/Amicale/AuthenticatedScreen';
import CustomTabBar from "../../../components/Tabbar/CustomTabBar"; import CustomHTML from '../../../components/Overrides/CustomHTML';
import type {category, club} from "./ClubListScreen"; import CustomTabBar from '../../../components/Tabbar/CustomTabBar';
import type {CustomTheme} from "../../../managers/ThemeManager"; import type {ClubCategoryType, ClubType} from './ClubListScreen';
import {StackNavigationProp} from "@react-navigation/stack"; import type {CustomTheme} from '../../../managers/ThemeManager';
import {ERROR_TYPE} from "../../../utils/WebData"; import {ERROR_TYPE} from '../../../utils/WebData';
import CollapsibleScrollView from "../../../components/Collapsible/CollapsibleScrollView"; import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView';
import type {ApiGenericDataType} from '../../../utils/WebData';
type Props = { type PropsType = {
navigation: StackNavigationProp, navigation: StackNavigationProp,
route: { route: {
params?: { params?: {
data?: club, data?: ClubType,
categories?: Array<category>, categories?: Array<ClubCategoryType>,
clubId?: number, clubId?: number,
}, ...
}, },
theme: CustomTheme ...
},
theme: CustomTheme,
}; };
type State = { const AMICALE_MAIL = 'clubs@amicale-insat.fr';
imageModalVisible: boolean,
};
const AMICALE_MAIL = "clubs@amicale-insat.fr";
/** /**
* Class defining a club event information page. * Class defining a club event information page.
* If called with data and categories navigation parameters, will use those to display the data. * If called with data and categories navigation parameters, will use those to display the data.
* If called with clubId parameter, will fetch the information on the server * If called with clubId parameter, will fetch the information on the server
*/ */
class ClubDisplayScreen extends React.Component<Props, State> { class ClubDisplayScreen extends React.Component<PropsType> {
displayData: ClubType | null;
displayData: club | null; categories: Array<ClubCategoryType> | null;
categories: Array<category> | null;
clubId: number;
shouldFetchData: boolean; clubId: number;
state = { shouldFetchData: boolean;
imageModalVisible: false,
};
constructor(props) { constructor(props: PropsType) {
super(props); super(props);
if (this.props.route.params != null) { if (props.route.params != null) {
if (this.props.route.params.data != null && this.props.route.params.categories != null) { if (
this.displayData = this.props.route.params.data; props.route.params.data != null &&
this.categories = this.props.route.params.categories; props.route.params.categories != null
this.clubId = this.props.route.params.data.id; ) {
this.shouldFetchData = false; this.displayData = props.route.params.data;
} else if (this.props.route.params.clubId != null) { this.categories = props.route.params.categories;
this.displayData = null; this.clubId = props.route.params.data.id;
this.categories = null; this.shouldFetchData = false;
this.clubId = this.props.route.params.clubId; } else if (props.route.params.clubId != null) {
this.shouldFetchData = true; this.displayData = null;
} this.categories = null;
} this.clubId = props.route.params.clubId;
this.shouldFetchData = true;
}
} }
}
/** /**
* Gets the name of the category with the given ID * Gets the name of the category with the given ID
* *
* @param id The category's ID * @param id The category's ID
* @returns {string|*} * @returns {string|*}
*/ */
getCategoryName(id: number) { getCategoryName(id: number): string {
if (this.categories !== null) { let categoryName = '';
for (let i = 0; i < this.categories.length; i++) { if (this.categories !== null) {
if (id === this.categories[i].id) this.categories.forEach((item: ClubCategoryType) => {
return this.categories[i].name; if (id === item.id) categoryName = item.name;
} });
}
return "";
} }
return categoryName;
}
/** /**
* Gets the view for rendering categories * Gets the view for rendering categories
* *
* @param categories The categories to display (max 2) * @param categories The categories to display (max 2)
* @returns {null|*} * @returns {null|*}
*/ */
getCategoriesRender(categories: [number, number]) { getCategoriesRender(categories: Array<number | null>): React.Node {
if (this.categories === null) if (this.categories == null) return null;
return null;
let final = []; const final = [];
for (let i = 0; i < categories.length; i++) { categories.forEach((cat: number | null) => {
let cat = categories[i]; if (cat != null) {
if (cat !== null) { final.push(
final.push( <Chip style={{marginRight: 5}} key={cat}>
<Chip {this.getCategoryName(cat)}
style={{marginRight: 5}} </Chip>,
key={i.toString()}>
{this.getCategoryName(cat)}
</Chip>
);
}
}
return <View style={{flexDirection: 'row', marginTop: 5}}>{final}</View>;
}
/**
* Gets the view for rendering club managers if any
*
* @param managers The list of manager names
* @param email The club contact email
* @returns {*}
*/
getManagersRender(managers: Array<string>, email: string | null) {
let managersListView = [];
for (let i = 0; i < managers.length; i++) {
managersListView.push(<Paragraph key={i.toString()}>{managers[i]}</Paragraph>)
}
const hasManagers = managers.length > 0;
return (
<Card style={{marginTop: 10, marginBottom: CustomTabBar.TAB_BAR_HEIGHT + 20}}>
<Card.Title
title={i18n.t('screens.clubs.managers')}
subtitle={hasManagers ? i18n.t('screens.clubs.managersSubtitle') : i18n.t('screens.clubs.managersUnavailable')}
left={(props) => <Avatar.Icon
{...props}
style={{backgroundColor: 'transparent'}}
color={hasManagers ? this.props.theme.colors.success : this.props.theme.colors.primary}
icon="account-tie"/>}
/>
<Card.Content>
{managersListView}
{this.getEmailButton(email, hasManagers)}
</Card.Content>
</Card>
); );
}
});
return <View style={{flexDirection: 'row', marginTop: 5}}>{final}</View>;
}
/**
* Gets the view for rendering club managers if any
*
* @param managers The list of manager names
* @param email The club contact email
* @returns {*}
*/
getManagersRender(managers: Array<string>, email: string | null): React.Node {
const {props} = this;
const managersListView = [];
managers.forEach((item: string) => {
managersListView.push(<Paragraph key={item}>{item}</Paragraph>);
});
const hasManagers = managers.length > 0;
return (
<Card
style={{marginTop: 10, marginBottom: CustomTabBar.TAB_BAR_HEIGHT + 20}}>
<Card.Title
title={i18n.t('screens.clubs.managers')}
subtitle={
hasManagers
? i18n.t('screens.clubs.managersSubtitle')
: i18n.t('screens.clubs.managersUnavailable')
}
left={({size}: {size: number}): React.Node => (
<Avatar.Icon
size={size}
style={{backgroundColor: 'transparent'}}
color={
hasManagers
? props.theme.colors.success
: props.theme.colors.primary
}
icon="account-tie"
/>
)}
/>
<Card.Content>
{managersListView}
{ClubDisplayScreen.getEmailButton(email, hasManagers)}
</Card.Content>
</Card>
);
}
/**
* Gets the email button to contact the club, or the amicale if the club does not have any managers
*
* @param email The club contact email
* @param hasManagers True if the club has managers
* @returns {*}
*/
static getEmailButton(
email: string | null,
hasManagers: boolean,
): React.Node {
const destinationEmail =
email != null && hasManagers ? email : AMICALE_MAIL;
const text =
email != null && hasManagers
? i18n.t('screens.clubs.clubContact')
: i18n.t('screens.clubs.amicaleContact');
return (
<Card.Actions>
<Button
icon="email"
mode="contained"
onPress={() => {
Linking.openURL(`mailto:${destinationEmail}`);
}}
style={{marginLeft: 'auto'}}>
{text}
</Button>
</Card.Actions>
);
}
getScreen = (response: Array<ApiGenericDataType | null>): React.Node => {
const {props} = this;
let data: ClubType | null = null;
if (response[0] != null) {
[data] = response;
this.updateHeaderTitle(data);
} }
if (data != null) {
return (
<CollapsibleScrollView style={{paddingLeft: 5, paddingRight: 5}} hasTab>
{this.getCategoriesRender(data.category)}
{data.logo !== null ? (
<View
style={{
marginLeft: 'auto',
marginRight: 'auto',
marginTop: 10,
marginBottom: 10,
}}>
<ImageModal
resizeMode="contain"
imageBackgroundColor={props.theme.colors.background}
style={{
width: 300,
height: 300,
}}
source={{
uri: data.logo,
}}
/>
</View>
) : (
<View />
)}
/** {data.description !== null ? (
* Gets the email button to contact the club, or the amicale if the club does not have any managers // Surround description with div to allow text styling if the description is not html
* <Card.Content>
* @param email The club contact email <CustomHTML html={data.description} />
* @param hasManagers True if the club has managers </Card.Content>
* @returns {*} ) : (
*/ <View />
getEmailButton(email: string | null, hasManagers: boolean) { )}
const destinationEmail = email != null && hasManagers {this.getManagersRender(data.responsibles, data.email)}
? email </CollapsibleScrollView>
: AMICALE_MAIL; );
const text = email != null && hasManagers
? i18n.t("screens.clubs.clubContact")
: i18n.t("screens.clubs.amicaleContact");
return (
<Card.Actions>
<Button
icon="email"
mode="contained"
onPress={() => Linking.openURL('mailto:' + destinationEmail)}
style={{marginLeft: 'auto'}}
>
{text}
</Button>
</Card.Actions>
);
} }
return null;
};
/** /**
* Updates the header title to match the given club * Updates the header title to match the given club
* *
* @param data The club data * @param data The club data
*/ */
updateHeaderTitle(data: club) { updateHeaderTitle(data: ClubType) {
this.props.navigation.setOptions({title: data.name}) const {props} = this;
} props.navigation.setOptions({title: data.name});
}
getScreen = (response: Array<{ [key: string]: any } | null>) => { render(): React.Node {
let data: club | null = null; const {props} = this;
if (response[0] != null) { if (this.shouldFetchData)
data = response[0]; return (
this.updateHeaderTitle(data); <AuthenticatedScreen
} navigation={props.navigation}
if (data != null) { requests={[
return ( {
<CollapsibleScrollView link: 'clubs/info',
style={{paddingLeft: 5, paddingRight: 5}} params: {id: this.clubId},
hasTab={true} mandatory: true,
> },
{this.getCategoriesRender(data.category)} ]}
{data.logo !== null ? renderFunction={this.getScreen}
<View style={{ errorViewOverride={[
marginLeft: 'auto', {
marginRight: 'auto', errorCode: ERROR_TYPE.BAD_INPUT,
marginTop: 10, message: i18n.t('screens.clubs.invalidClub'),
marginBottom: 10, icon: 'account-question',
}}> showRetryButton: false,
<ImageModal },
resizeMode="contain" ]}
imageBackgroundColor={this.props.theme.colors.background} />
style={{ );
width: 300, return this.getScreen([this.displayData]);
height: 300, }
}}
source={{
uri: data.logo,
}}
/>
</View>
: <View/>}
{data.description !== null ?
// Surround description with div to allow text styling if the description is not html
<Card.Content>
<CustomHTML html={data.description}/>
</Card.Content>
: <View/>}
{this.getManagersRender(data.responsibles, data.email)}
</CollapsibleScrollView>
);
} else
return null;
};
render() {
if (this.shouldFetchData)
return <AuthenticatedScreen
{...this.props}
requests={[
{
link: 'clubs/info',
params: {'id': this.clubId},
mandatory: true
}
]}
renderFunction={this.getScreen}
errorViewOverride={[
{
errorCode: ERROR_TYPE.BAD_INPUT,
message: i18n.t("screens.clubs.invalidClub"),
icon: "account-question",
showRetryButton: false
}
]}
/>;
else
return this.getScreen([this.displayData]);
}
} }
export default withTheme(ClubDisplayScreen); export default withTheme(ClubDisplayScreen);

View file

@ -1,237 +1,271 @@
// @flow // @flow
import * as React from 'react'; import * as React from 'react';
import {Platform} from "react-native"; import {Platform} from 'react-native';
import {Searchbar} from 'react-native-paper'; import {Searchbar} from 'react-native-paper';
import AuthenticatedScreen from "../../../components/Amicale/AuthenticatedScreen"; import i18n from 'i18n-js';
import i18n from "i18n-js"; import {StackNavigationProp} from '@react-navigation/stack';
import ClubListItem from "../../../components/Lists/Clubs/ClubListItem"; import AuthenticatedScreen from '../../../components/Amicale/AuthenticatedScreen';
import {isItemInCategoryFilter, stringMatchQuery} from "../../../utils/Search"; import ClubListItem from '../../../components/Lists/Clubs/ClubListItem';
import ClubListHeader from "../../../components/Lists/Clubs/ClubListHeader"; import {isItemInCategoryFilter, stringMatchQuery} from '../../../utils/Search';
import MaterialHeaderButtons, {Item} from "../../../components/Overrides/CustomHeaderButton"; import ClubListHeader from '../../../components/Lists/Clubs/ClubListHeader';
import {StackNavigationProp} from "@react-navigation/stack"; import MaterialHeaderButtons, {
import type {CustomTheme} from "../../../managers/ThemeManager"; Item,
import CollapsibleFlatList from "../../../components/Collapsible/CollapsibleFlatList"; } from '../../../components/Overrides/CustomHeaderButton';
import CollapsibleFlatList from '../../../components/Collapsible/CollapsibleFlatList';
export type category = { export type ClubCategoryType = {
id: number, id: number,
name: string, name: string,
}; };
export type club = { export type ClubType = {
id: number, id: number,
name: string, name: string,
description: string, description: string,
logo: string, logo: string,
email: string | null, email: string | null,
category: [number, number], category: Array<number | null>,
responsibles: Array<string>, responsibles: Array<string>,
}; };
type Props = { type PropsType = {
navigation: StackNavigationProp, navigation: StackNavigationProp,
theme: CustomTheme, };
}
type State = { type StateType = {
currentlySelectedCategories: Array<number>, currentlySelectedCategories: Array<number>,
currentSearchString: string, currentSearchString: string,
} };
const LIST_ITEM_HEIGHT = 96; const LIST_ITEM_HEIGHT = 96;
class ClubListScreen extends React.Component<Props, State> { class ClubListScreen extends React.Component<PropsType, StateType> {
categories: Array<ClubCategoryType>;
state = { constructor() {
currentlySelectedCategories: [], super();
currentSearchString: '', this.state = {
currentlySelectedCategories: [],
currentSearchString: '',
}; };
}
categories: Array<category>; /**
* Creates the header content
*/
componentDidMount() {
const {props} = this;
props.navigation.setOptions({
headerTitle: this.getSearchBar,
headerRight: this.getHeaderButtons,
headerBackTitleVisible: false,
headerTitleContainerStyle:
Platform.OS === 'ios'
? {marginHorizontal: 0, width: '70%'}
: {marginHorizontal: 0, right: 50, left: 50},
});
}
/** /**
* Creates the header content * Callback used when clicking an article in the list.
*/ * It opens the modal to show detailed information about the article
componentDidMount() { *
this.props.navigation.setOptions({ * @param item The article pressed
headerTitle: this.getSearchBar, */
headerRight: this.getHeaderButtons, onListItemPress(item: ClubType) {
headerBackTitleVisible: false, const {props} = this;
headerTitleContainerStyle: Platform.OS === 'ios' ? props.navigation.navigate('club-information', {
{marginHorizontal: 0, width: '70%'} : data: item,
{marginHorizontal: 0, right: 50, left: 50}, categories: this.categories,
}); });
}
/**
* Callback used when the search changes
*
* @param str The new search string
*/
onSearchStringChange = (str: string) => {
this.updateFilteredData(str, null);
};
/**
* Gets the header search bar
*
* @return {*}
*/
getSearchBar = (): React.Node => {
return (
<Searchbar
placeholder={i18n.t('screens.proximo.search')}
onChangeText={this.onSearchStringChange}
/>
);
};
onChipSelect = (id: number) => {
this.updateFilteredData(null, id);
};
/**
* Gets the header button
* @return {*}
*/
getHeaderButtons = (): React.Node => {
const onPress = () => {
const {props} = this;
props.navigation.navigate('club-about');
};
return (
<MaterialHeaderButtons>
<Item title="main" iconName="information" onPress={onPress} />
</MaterialHeaderButtons>
);
};
getScreen = (
data: Array<{
categories: Array<ClubCategoryType>,
clubs: Array<ClubType>,
} | null>,
): React.Node => {
let categoryList = [];
let clubList = [];
if (data[0] != null) {
categoryList = data[0].categories;
clubList = data[0].clubs;
} }
this.categories = categoryList;
return (
<CollapsibleFlatList
data={clubList}
keyExtractor={this.keyExtractor}
renderItem={this.getRenderItem}
ListHeaderComponent={this.getListHeader()}
// Performance props, see https://reactnative.dev/docs/optimizing-flatlist-configuration
removeClippedSubviews
getItemLayout={this.itemLayout}
/>
);
};
/** /**
* Gets the header search bar * Gets the list header, with controls to change the categories filter
* *
* @return {*} * @returns {*}
*/ */
getSearchBar = () => { getListHeader(): React.Node {
return ( const {state} = this;
<Searchbar return (
placeholder={i18n.t('screens.proximo.search')} <ClubListHeader
onChangeText={this.onSearchStringChange} categories={this.categories}
/> selectedCategories={state.currentlySelectedCategories}
); onChipSelect={this.onChipSelect}
/>
);
}
/**
* Gets the category object of the given ID
*
* @param id The ID of the category to find
* @returns {*}
*/
getCategoryOfId = (id: number): ClubCategoryType | null => {
let cat = null;
this.categories.forEach((item: ClubCategoryType) => {
if (id === item.id) cat = item;
});
return cat;
};
getRenderItem = ({item}: {item: ClubType}): React.Node => {
const onPress = () => {
this.onListItemPress(item);
}; };
if (this.shouldRenderItem(item)) {
/** return (
* Gets the header button <ClubListItem
* @return {*} categoryTranslator={this.getCategoryOfId}
*/ item={item}
getHeaderButtons = () => { onPress={onPress}
const onPress = () => this.props.navigation.navigate("club-about"); height={LIST_ITEM_HEIGHT}
return <MaterialHeaderButtons> />
<Item title="main" iconName="information" onPress={onPress}/> );
</MaterialHeaderButtons>;
};
/**
* Callback used when the search changes
*
* @param str The new search string
*/
onSearchStringChange = (str: string) => {
this.updateFilteredData(str, null);
};
keyExtractor = (item: club) => item.id.toString();
itemLayout = (data, index) => ({length: LIST_ITEM_HEIGHT, offset: LIST_ITEM_HEIGHT * index, index});
getScreen = (data: Array<{ categories: Array<category>, clubs: Array<club> } | null>) => {
let categoryList = [];
let clubList = [];
if (data[0] != null) {
categoryList = data[0].categories;
clubList = data[0].clubs;
}
this.categories = categoryList;
return (
<CollapsibleFlatList
data={clubList}
keyExtractor={this.keyExtractor}
renderItem={this.getRenderItem}
ListHeaderComponent={this.getListHeader()}
// Performance props, see https://reactnative.dev/docs/optimizing-flatlist-configuration
removeClippedSubviews={true}
getItemLayout={this.itemLayout}
/>
)
};
onChipSelect = (id: number) => this.updateFilteredData(null, id);
/**
* Updates the search string and category filter, saving them to the State.
*
* If the given category is already in the filter, it removes it.
* Otherwise it adds it to the filter.
*
* @param filterStr The new filter string to use
* @param categoryId The category to add/remove from the filter
*/
updateFilteredData(filterStr: string | null, categoryId: number | null) {
let newCategoriesState = [...this.state.currentlySelectedCategories];
let newStrState = this.state.currentSearchString;
if (filterStr !== null)
newStrState = filterStr;
if (categoryId !== null) {
let index = newCategoriesState.indexOf(categoryId);
if (index === -1)
newCategoriesState.push(categoryId);
else
newCategoriesState.splice(index, 1);
}
if (filterStr !== null || categoryId !== null)
this.setState({
currentSearchString: newStrState,
currentlySelectedCategories: newCategoriesState,
})
} }
return null;
};
/** keyExtractor = (item: ClubType): string => item.id.toString();
* Gets the list header, with controls to change the categories filter
* itemLayout = (
* @returns {*} data: {...},
*/ index: number,
getListHeader() { ): {length: number, offset: number, index: number} => ({
return <ClubListHeader length: LIST_ITEM_HEIGHT,
categories={this.categories} offset: LIST_ITEM_HEIGHT * index,
selectedCategories={this.state.currentlySelectedCategories} index,
onChipSelect={this.onChipSelect} });
/>;
/**
* Updates the search string and category filter, saving them to the State.
*
* If the given category is already in the filter, it removes it.
* Otherwise it adds it to the filter.
*
* @param filterStr The new filter string to use
* @param categoryId The category to add/remove from the filter
*/
updateFilteredData(filterStr: string | null, categoryId: number | null) {
const {state} = this;
const newCategoriesState = [...state.currentlySelectedCategories];
let newStrState = state.currentSearchString;
if (filterStr !== null) newStrState = filterStr;
if (categoryId !== null) {
const index = newCategoriesState.indexOf(categoryId);
if (index === -1) newCategoriesState.push(categoryId);
else newCategoriesState.splice(index, 1);
} }
if (filterStr !== null || categoryId !== null)
this.setState({
currentSearchString: newStrState,
currentlySelectedCategories: newCategoriesState,
});
}
/** /**
* Gets the category object of the given ID * Checks if the given item should be rendered according to current name and category filters
* *
* @param id The ID of the category to find * @param item The club to check
* @returns {*} * @returns {boolean}
*/ */
getCategoryOfId = (id: number) => { shouldRenderItem(item: ClubType): boolean {
for (let i = 0; i < this.categories.length; i++) { const {state} = this;
if (id === this.categories[i].id) let shouldRender =
return this.categories[i]; state.currentlySelectedCategories.length === 0 ||
} isItemInCategoryFilter(state.currentlySelectedCategories, item.category);
}; if (shouldRender)
shouldRender = stringMatchQuery(item.name, state.currentSearchString);
return shouldRender;
}
/** render(): React.Node {
* Checks if the given item should be rendered according to current name and category filters const {props} = this;
* return (
* @param item The club to check <AuthenticatedScreen
* @returns {boolean} navigation={props.navigation}
*/ requests={[
shouldRenderItem(item: club) { {
let shouldRender = this.state.currentlySelectedCategories.length === 0 link: 'clubs/list',
|| isItemInCategoryFilter(this.state.currentlySelectedCategories, item.category); params: {},
if (shouldRender) mandatory: true,
shouldRender = stringMatchQuery(item.name, this.state.currentSearchString); },
return shouldRender; ]}
} renderFunction={this.getScreen}
/>
getRenderItem = ({item}: { item: club }) => { );
const onPress = this.onListItemPress.bind(this, item); }
if (this.shouldRenderItem(item)) {
return (
<ClubListItem
categoryTranslator={this.getCategoryOfId}
item={item}
onPress={onPress}
height={LIST_ITEM_HEIGHT}
/>
);
} else
return null;
};
/**
* Callback used when clicking an article in the list.
* It opens the modal to show detailed information about the article
*
* @param item The article pressed
*/
onListItemPress(item: club) {
this.props.navigation.navigate("club-information", {data: item, categories: this.categories});
}
render() {
return (
<AuthenticatedScreen
{...this.props}
requests={[
{
link: 'clubs/list',
params: {},
mandatory: true,
}
]}
renderFunction={this.getScreen}
/>
);
}
} }
export default ClubListScreen; export default ClubListScreen;

View file

@ -1,6 +1,5 @@
// @flow // @flow
/** /**
* Sanitizes the given string to improve search performance. * Sanitizes the given string to improve search performance.
* *
@ -10,11 +9,12 @@
* @return {string} The sanitized string * @return {string} The sanitized string
*/ */
export function sanitizeString(str: string): string { export function sanitizeString(str: string): string {
return str.toLowerCase() return str
.normalize("NFD") .toLowerCase()
.replace(/[\u0300-\u036f]/g, "") .normalize('NFD')
.replace(/ /g, "") .replace(/[\u0300-\u036f]/g, '')
.replace(/_/g, ""); .replace(/ /g, '')
.replace(/_/g, '');
} }
/** /**
@ -24,8 +24,8 @@ export function sanitizeString(str: string): string {
* @param query The query string used to find a match * @param query The query string used to find a match
* @returns {boolean} * @returns {boolean}
*/ */
export function stringMatchQuery(str: string, query: string) { export function stringMatchQuery(str: string, query: string): boolean {
return sanitizeString(str).includes(sanitizeString(query)); return sanitizeString(str).includes(sanitizeString(query));
} }
/** /**
@ -35,10 +35,13 @@ export function stringMatchQuery(str: string, query: string) {
* @param categories The item's categories tuple * @param categories The item's categories tuple
* @returns {boolean} True if at least one entry is in both arrays * @returns {boolean} True if at least one entry is in both arrays
*/ */
export function isItemInCategoryFilter(filter: Array<number>, categories: [number, number]) { export function isItemInCategoryFilter(
for (const category of categories) { filter: Array<number>,
if (filter.indexOf(category) !== -1) categories: Array<number | null>,
return true; ): boolean {
} let itemFound = false;
return false; categories.forEach((cat: number | null) => {
if (cat != null && filter.indexOf(cat) !== -1) itemFound = true;
});
return itemFound;
} }