Improved app links and error handling on qr code opening

This commit is contained in:
Arnaud Vergnet 2020-04-08 20:11:39 +02:00
parent 53daa6671a
commit 71f39a64cc
11 changed files with 147 additions and 67 deletions

View file

@ -1,14 +1,12 @@
// @flow // @flow
import * as React from 'react'; import * as React from 'react';
import {withTheme} from 'react-native-paper';
import ConnectionManager, {ERROR_TYPE} from "../../managers/ConnectionManager"; import ConnectionManager, {ERROR_TYPE} from "../../managers/ConnectionManager";
import ErrorView from "../Custom/ErrorView"; import ErrorView from "../Custom/ErrorView";
import BasicLoadingScreen from "../Custom/BasicLoadingScreen"; import BasicLoadingScreen from "../Custom/BasicLoadingScreen";
type Props = { type Props = {
navigation: Object, navigation: Object,
theme: Object,
links: Array<{link: string, mandatory: boolean}>, links: Array<{link: string, mandatory: boolean}>,
renderFunction: Function, renderFunction: Function,
} }
@ -25,24 +23,35 @@ class AuthenticatedScreen extends React.Component<Props, State> {
currentUserToken: string | null; currentUserToken: string | null;
connectionManager: ConnectionManager; connectionManager: ConnectionManager;
errorCode: number; errors: Array<number>;
data: Array<Object>; fetchedData: Array<Object>;
colors: Object;
constructor(props) { constructor(props: Object) {
super(props); super(props);
this.colors = props.theme.colors;
this.connectionManager = ConnectionManager.getInstance(); this.connectionManager = ConnectionManager.getInstance();
this.props.navigation.addListener('focus', this.onScreenFocus.bind(this)); this.props.navigation.addListener('focus', this.onScreenFocus);
this.data = new Array(this.props.links.length); this.fetchedData = new Array(this.props.links.length);
this.errors = new Array(this.props.links.length);
this.fetchData(); // TODO remove in prod (only use for fast refresh) this.fetchData(); // TODO remove in prod (only use for fast refresh)
} }
onScreenFocus() { /**
if (this.currentUserToken !== this.connectionManager.getToken()) * Refreshes screen if user changed
*/
onScreenFocus = () => {
if (this.currentUserToken !== this.connectionManager.getToken()){
this.currentUserToken = this.connectionManager.getToken();
this.fetchData(); this.fetchData();
} }
};
/**
* Fetches the data from the server.
*
* If the user is not logged in errorCode is set to BAD_TOKEN and all requests fail.
*
* If the user is logged in, send all requests.
*/
fetchData = () => { fetchData = () => {
if (!this.state.loading) if (!this.state.loading)
this.setState({loading: true}); this.setState({loading: true});
@ -50,39 +59,51 @@ class AuthenticatedScreen extends React.Component<Props, State> {
for (let i = 0; i < this.props.links.length; i++) { for (let i = 0; i < this.props.links.length; i++) {
this.connectionManager.authenticatedRequest(this.props.links[i].link, null, null) this.connectionManager.authenticatedRequest(this.props.links[i].link, null, null)
.then((data) => { .then((data) => {
this.onFinishedLoading(data, i, -1); this.onRequestFinished(data, i, -1);
}) })
.catch((error) => { .catch((error) => {
this.onFinishedLoading(null, i, error); this.onRequestFinished(null, i, error);
}); });
} }
} else { } else {
this.onFinishedLoading(null, -1, ERROR_TYPE.BAD_CREDENTIALS); for (let i = 0; i < this.props.links.length; i++) {
this.onRequestFinished(null, i, ERROR_TYPE.BAD_TOKEN);
}
} }
}; };
onFinishedLoading(data: Object, index: number, error: number) { /**
if (index >= 0 && index < this.props.links.length) * Callback used when a request finishes, successfully or not.
this.data[index] = data; * Saves data and error code.
this.currentUserToken = data !== undefined * If the token is invalid, logout the user and open the login screen.
? this.connectionManager.getToken() * If the last request was received, stop the loading screen.
: null; *
this.errorCode = error; * @param data The data fetched from the server
* @param index The index for the data
* @param error The error code received
*/
onRequestFinished(data: Object | null, index: number, error: number) {
if (index >= 0 && index < this.props.links.length){
this.fetchedData[index] = data;
this.errors[index] = error;
}
if (this.errorCode === ERROR_TYPE.BAD_TOKEN) { // Token expired, logout user if (error === ERROR_TYPE.BAD_TOKEN) // Token expired, logout user
this.connectionManager.disconnect() this.connectionManager.disconnect();
.then(() => {
this.props.navigation.navigate("login"); if (this.allRequestsFinished())
});
} else if (this.allRequestsFinished())
this.setState({loading: false}); this.setState({loading: false});
} }
/**
* Checks if all requests finished processing
*
* @return {boolean} True if all finished
*/
allRequestsFinished() { allRequestsFinished() {
let finished = true; let finished = true;
for (let i = 0; i < this.data.length; i++) { for (let i = 0; i < this.fetchedData.length; i++) {
if (this.data[i] === undefined) { if (this.fetchedData[i] === undefined) {
finished = false; finished = false;
break; break;
} }
@ -90,10 +111,17 @@ class AuthenticatedScreen extends React.Component<Props, State> {
return finished; return finished;
} }
/**
* Checks if all requests have finished successfully.
* This will return false only if a mandatory request failed.
* All non-mandatory requests can fail without impacting the return value.
*
* @return {boolean} True if all finished successfully
*/
allRequestsValid() { allRequestsValid() {
let valid = true; let valid = true;
for (let i = 0; i < this.data.length; i++) { for (let i = 0; i < this.fetchedData.length; i++) {
if (this.data[i] === null && this.props.links[i].mandatory) { if (this.fetchedData[i] === null && this.props.links[i].mandatory) {
valid = false; valid = false;
break; break;
} }
@ -101,15 +129,40 @@ class AuthenticatedScreen extends React.Component<Props, State> {
return valid; return valid;
} }
/**
* Gets the error to render.
* Non-mandatory requests are ignored.
*
*
* @return {number} The error code or ERROR_TYPE.SUCCESS if no error was found
*/
getError() {
for (let i = 0; i < this.errors.length; i++) {
if (this.errors[i] !== 0 && this.props.links[i].mandatory) {
return this.errors[i];
}
}
return ERROR_TYPE.SUCCESS;
}
/**
* Gets the error view to display in case of error
*
* @return {*}
*/
getErrorRender() { getErrorRender() {
return ( return (
<ErrorView <ErrorView
errorCode={this.errorCode} {...this.props}
errorCode={this.getError()}
onRefresh={this.fetchData} onRefresh={this.fetchData}
/> />
); );
} }
/**
* Reloads the data, to be called using ref by parent components
*/
reload() { reload() {
this.fetchData(); this.fetchData();
} }
@ -119,10 +172,10 @@ class AuthenticatedScreen extends React.Component<Props, State> {
this.state.loading this.state.loading
? <BasicLoadingScreen/> ? <BasicLoadingScreen/>
: (this.allRequestsValid() : (this.allRequestsValid()
? this.props.renderFunction(this.data) ? this.props.renderFunction(this.fetchedData)
: this.getErrorRender()) : this.getErrorRender())
); );
} }
} }
export default withTheme(AuthenticatedScreen); export default AuthenticatedScreen;

View file

@ -8,6 +8,7 @@ import i18n from 'i18n-js';
import {ERROR_TYPE} from "../../managers/ConnectionManager"; import {ERROR_TYPE} from "../../managers/ConnectionManager";
type Props = { type Props = {
navigation: Object,
errorCode: number, errorCode: number,
onRefresh: Function, onRefresh: Function,
} }
@ -23,6 +24,8 @@ class ErrorView extends React.PureComponent<Props, State> {
message: string; message: string;
icon: string; icon: string;
showLoginButton: boolean;
state = { state = {
refreshing: false, refreshing: false,
}; };
@ -33,6 +36,7 @@ class ErrorView extends React.PureComponent<Props, State> {
} }
generateMessage() { generateMessage() {
this.showLoginButton = false;
switch (this.props.errorCode) { switch (this.props.errorCode) {
case ERROR_TYPE.BAD_CREDENTIALS: case ERROR_TYPE.BAD_CREDENTIALS:
this.message = i18n.t("errors.badCredentials"); this.message = i18n.t("errors.badCredentials");
@ -41,6 +45,7 @@ class ErrorView extends React.PureComponent<Props, State> {
case ERROR_TYPE.BAD_TOKEN: case ERROR_TYPE.BAD_TOKEN:
this.message = i18n.t("errors.badToken"); this.message = i18n.t("errors.badToken");
this.icon = "account-alert-outline"; this.icon = "account-alert-outline";
this.showLoginButton = true;
break; break;
case ERROR_TYPE.NO_CONSENT: case ERROR_TYPE.NO_CONSENT:
this.message = i18n.t("errors.noConsent"); this.message = i18n.t("errors.noConsent");
@ -69,6 +74,30 @@ class ErrorView extends React.PureComponent<Props, State> {
} }
} }
getRetryButton() {
return <Button
mode={'contained'}
icon={'refresh'}
onPress={this.props.onRefresh}
style={styles.button}
>
{i18n.t("general.retry")}
</Button>;
}
goToLogin = () => this.props.navigation.navigate("login");
getLoginButton() {
return <Button
mode={'contained'}
icon={'login'}
onPress={this.goToLogin}
style={styles.button}
>
{i18n.t("screens.login")}
</Button>;
}
render() { render() {
this.generateMessage(); this.generateMessage();
return ( return (
@ -89,14 +118,9 @@ class ErrorView extends React.PureComponent<Props, State> {
}}> }}>
{this.message} {this.message}
</Subheading> </Subheading>
<Button {this.showLoginButton
mode={'contained'} ? this.getLoginButton()
icon={'refresh'} : this.getRetryButton()}
onPress={this.props.onRefresh}
style={styles.button}
>
{i18n.t("general.retry")}
</Button>
</View> </View>
</View> </View>
); );

View file

@ -194,6 +194,7 @@ export default class WebSectionList extends React.PureComponent<Props, State> {
ListEmptyComponent={this.state.refreshing ListEmptyComponent={this.state.refreshing
? <BasicLoadingScreen/> ? <BasicLoadingScreen/>
: <ErrorView : <ErrorView
{...this.props}
errorCode={ERROR_TYPE.CONNECTION_ERROR} errorCode={ERROR_TYPE.CONNECTION_ERROR}
onRefresh={this.onRefresh}/> onRefresh={this.onRefresh}/>
} }

View file

@ -181,7 +181,7 @@ function HomeStackComponent(initialRoute: string | null, defaultData: Object) {
component={ClubDisplayScreen} component={ClubDisplayScreen}
options={({navigation}) => { options={({navigation}) => {
return { return {
title: '', title: i18n.t('screens.clubDisplayScreen'),
...TransitionPresets.ModalSlideFromBottomIOS, ...TransitionPresets.ModalSlideFromBottomIOS,
}; };
}} }}

View file

@ -33,7 +33,6 @@ class DebugScreen extends React.Component<Props, State> {
this.onModalRef = this.onModalRef.bind(this); this.onModalRef = this.onModalRef.bind(this);
this.colors = props.theme.colors; this.colors = props.theme.colors;
let copy = {...AsyncStorageManager.getInstance().preferences}; let copy = {...AsyncStorageManager.getInstance().preferences};
console.log(copy);
let currentPreferences = []; let currentPreferences = [];
Object.values(copy).map((object) => { Object.values(copy).map((object) => {
currentPreferences.push(object); currentPreferences.push(object);

View file

@ -39,7 +39,7 @@ const FakeClub = {
}; };
/** /**
* Class defining a planning 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
*/ */
@ -61,7 +61,6 @@ class ClubDisplayScreen extends React.Component<Props, State> {
super(props); super(props);
this.colors = props.theme.colors; this.colors = props.theme.colors;
console.log(this.props.route.params);
if (this.props.route.params.data !== undefined && this.props.route.params.categories !== undefined) { if (this.props.route.params.data !== undefined && this.props.route.params.categories !== undefined) {
this.displayData = this.props.route.params.data; this.displayData = this.props.route.params.data;
this.categories = this.props.route.params.categories; this.categories = this.props.route.params.categories;
@ -72,7 +71,6 @@ class ClubDisplayScreen extends React.Component<Props, State> {
this.categories = null; this.categories = null;
this.clubId = this.props.route.params.clubId; this.clubId = this.props.route.params.clubId;
this.shouldFetchData = true; this.shouldFetchData = true;
console.log(this.clubId);
} }
} }
@ -135,6 +133,8 @@ class ClubDisplayScreen extends React.Component<Props, State> {
} }
getScreen = (data: Object) => { getScreen = (data: Object) => {
console.log('fetchedData passed to screen:');
console.log(data);
data = FakeClub; data = FakeClub;
this.updateHeaderTitle(data); this.updateHeaderTitle(data);
@ -183,8 +183,8 @@ class ClubDisplayScreen extends React.Component<Props, State> {
{...this.props} {...this.props}
links={[ links={[
{ {
link: 'clubs/list/' + this.clubId, link: 'clubs/' + this.clubId,
mandatory: false, mandatory: true,
} }
]} ]}
renderFunction={this.getScreen} renderFunction={this.getScreen}

View file

@ -235,7 +235,7 @@ class ProxiwashScreen extends React.Component<Props, State> {
} }
/** /**
* Sets the given data as the watchlist * Sets the given fetchedData as the watchlist
* *
* @param data * @param data
*/ */

View file

@ -42,7 +42,6 @@ class ScannerScreen extends React.Component<Props, State> {
updatePermissionStatus = ({status}) => this.setState({hasPermission: status === "granted"}); updatePermissionStatus = ({status}) => this.setState({hasPermission: status === "granted"});
handleCodeScanned = ({type, data}) => { handleCodeScanned = ({type, data}) => {
if (!URLHandler.isUrlValid(data)) if (!URLHandler.isUrlValid(data))
this.showErrorDialog(); this.showErrorDialog();
else { else {

View file

@ -4,6 +4,9 @@ import {Linking} from 'expo';
export default class URLHandler { export default class URLHandler {
static CLUB_INFO_URL_PATH = "club";
static EVENT_INFO_URL_PATH = "event";
static CLUB_INFO_ROUTE = "club-information"; static CLUB_INFO_ROUTE = "club-information";
static EVENT_INFO_ROUTE = "planning-information"; static EVENT_INFO_ROUTE = "planning-information";
@ -16,7 +19,6 @@ export default class URLHandler {
} }
listen() { listen() {
console.log(Linking.makeUrl('main/home/club-information', {clubId: 1}));
Linking.addEventListener('url', this.onUrl); Linking.addEventListener('url', this.onUrl);
Linking.parseInitialURLAsync().then(this.onInitialUrl); Linking.parseInitialURLAsync().then(this.onInitialUrl);
} }
@ -34,12 +36,12 @@ export default class URLHandler {
}; };
static getUrlData({path, queryParams}: Object) { static getUrlData({path, queryParams}: Object) {
console.log(path);
let data = null; let data = null;
if (path !== null) { if (path !== null) {
let pathArray = path.split('/'); if (URLHandler.isClubInformationLink(path))
if (URLHandler.isClubInformationLink(pathArray))
data = URLHandler.generateClubInformationData(queryParams); data = URLHandler.generateClubInformationData(queryParams);
else if (URLHandler.isPlanningInformationLink(pathArray)) else if (URLHandler.isPlanningInformationLink(path))
data = URLHandler.generatePlanningInformationData(queryParams); data = URLHandler.generatePlanningInformationData(queryParams);
} }
return data; return data;
@ -49,17 +51,17 @@ export default class URLHandler {
return this.getUrlData(Linking.parse(url)) !== null; return this.getUrlData(Linking.parse(url)) !== null;
} }
static isClubInformationLink(pathArray: Array<string>) { static isClubInformationLink(path: string) {
return pathArray[0] === "main" && pathArray[1] === "home" && pathArray[2] === "club-information"; return path === URLHandler.CLUB_INFO_URL_PATH;
} }
static isPlanningInformationLink(pathArray: Array<string>) { static isPlanningInformationLink(path: string) {
return pathArray[0] === "main" && pathArray[1] === "home" && pathArray[2] === "planning-information"; return path === URLHandler.EVENT_INFO_URL_PATH;
} }
static generateClubInformationData(params: Object): Object | null { static generateClubInformationData(params: Object): Object | null {
if (params !== undefined && params.clubId !== undefined) { if (params !== undefined && params.id !== undefined) {
let id = parseInt(params.clubId); let id = parseInt(params.id);
if (!isNaN(id)) { if (!isNaN(id)) {
return {route: URLHandler.CLUB_INFO_ROUTE, data: {clubId: id}}; return {route: URLHandler.CLUB_INFO_ROUTE, data: {clubId: id}};
} }
@ -68,8 +70,8 @@ export default class URLHandler {
} }
static generatePlanningInformationData(params: Object): Object | null { static generatePlanningInformationData(params: Object): Object | null {
if (params !== undefined && params.eventId !== undefined) { if (params !== undefined && params.id !== undefined) {
let id = parseInt(params.eventId); let id = parseInt(params.id);
if (!isNaN(id)) { if (!isNaN(id)) {
return {route: URLHandler.EVENT_INFO_ROUTE, data: {eventId: id}}; return {route: URLHandler.EVENT_INFO_ROUTE, data: {eventId: id}};
} }

View file

@ -3,6 +3,7 @@
"home": "Home", "home": "Home",
"planning": "Planning", "planning": "Planning",
"planningDisplayScreen": "Event details", "planningDisplayScreen": "Event details",
"clubDisplayScreen": "Club details",
"proxiwash": "Proxiwash", "proxiwash": "Proxiwash",
"proximo": "Proximo", "proximo": "Proximo",
"proximoArticles": "Articles", "proximoArticles": "Articles",
@ -253,7 +254,7 @@
"errors": { "errors": {
"title": "Error!", "title": "Error!",
"badCredentials": "Email or password invalid.", "badCredentials": "Email or password invalid.",
"badToken": "Session expired, please login again.", "badToken": "You are not logged in. Please login and try again.",
"noConsent": "You did not give your consent for data processing to the Amicale.", "noConsent": "You did not give your consent for data processing to the Amicale.",
"badInput": "Invalid input. Please try again.", "badInput": "Invalid input. Please try again.",
"forbidden": "You do not have access to this data.", "forbidden": "You do not have access to this data.",

View file

@ -3,6 +3,7 @@
"home": "Accueil", "home": "Accueil",
"planning": "Planning", "planning": "Planning",
"planningDisplayScreen": "Détails", "planningDisplayScreen": "Détails",
"clubDisplayScreen": "Détails",
"proxiwash": "Proxiwash", "proxiwash": "Proxiwash",
"proximo": "Proximo", "proximo": "Proximo",
"proximoArticles": "Articles", "proximoArticles": "Articles",
@ -253,7 +254,7 @@
"errors": { "errors": {
"title": "Erreur !", "title": "Erreur !",
"badCredentials": "Email ou mot de passe invalide.", "badCredentials": "Email ou mot de passe invalide.",
"badToken": "Session expirée, merci de vous reconnecter.", "badToken": "Vous n'êtes pas connecté. Merci de vous connecter puis réessayez.",
"noConsent": "Vous n'avez pas donné votre consentement pour l'utilisation de vos données personnelles.", "noConsent": "Vous n'avez pas donné votre consentement pour l'utilisation de vos données personnelles.",
"badInput": "Entrée invalide. Merci de réessayer.", "badInput": "Entrée invalide. Merci de réessayer.",
"forbidden": "Vous n'avez pas accès à cette information.", "forbidden": "Vous n'avez pas accès à cette information.",