Skip to content

Commit

Permalink
feat: add loader wait management popup (axelor#367)
Browse files Browse the repository at this point in the history
* RM#73990
  • Loading branch information
hel-axelor authored and vhu-axelor committed Jun 13, 2024
1 parent 7ca20f4 commit e12bd1e
Show file tree
Hide file tree
Showing 9 changed files with 466 additions and 68 deletions.
43 changes: 29 additions & 14 deletions packages/apps/stock/src/screens/loader/LoaderScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,34 +34,49 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React from 'react';
import {Button, View} from 'react-native';
import {Screen} from '@axelor/aos-mobile-ui';
import {useLoaderListner} from '@axelor/aos-mobile-core';
import React, {useState} from 'react';
import {View} from 'react-native';
import {Button, Screen} from '@axelor/aos-mobile-ui';
import {LoaderPopup} from '@axelor/aos-mobile-core';

// Screen for test Loader functionnalities
const LoaderScreen = () => {
const [start, setStart] = useState(false);

const process = () =>
new Promise(resolve => {
new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Process finished');
resolve('Process completed');
// reject('Process failed');
}, 10000);
});

const handleCustomAction = () => {
console.log('Custom action executed!');
const handleSuccessAction = () => {
setStart(false);
console.log('Success action executed!');
};

const {loading, listener} = useLoaderListner({
process,
onSuccess: handleCustomAction,
onError: () => console.warn('An error has occurred!'),
});
const handleErrorAction = () => {
setStart(false);
console.log('Error action executed!');
};

return (
<Screen>
<View>
<Button title="check process" onPress={listener} disabled={loading} />
<Button
title="Run process"
onPress={() => setStart(true)}
disabled={start}
/>
<LoaderPopup
start={start}
process={process}
timeout={5000}
onSuccess={handleSuccessAction}
onError={handleErrorAction}
disabled={false}
/>
</View>
</Screen>
);
Expand Down
150 changes: 150 additions & 0 deletions packages/core/src/components/templates/Loader/LoaderPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Axelor Business Solutions
*
* Copyright (C) 2024 Axelor (<http://axelor.com>).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, {useCallback, useEffect, useRef, useState} from 'react';
import {ActivityIndicator, Dimensions, StyleSheet, View} from 'react-native';
import {
BlockInteractionScreen,
Button,
Card,
Label,
Text,
useConfig,
useThemeColor,
} from '@axelor/aos-mobile-ui';
import {useTranslator} from '../../../i18n';
import {useNavigation} from '../../../hooks/use-navigation';
import useLoaderListner from './use-loader-listener';
import {processProvider} from './ProcessProvider';

interface LoaderPopupProps {
process: () => Promise<any>;
onSuccess: () => void;
onError: () => void;
start?: boolean;
disabled?: boolean;
timeout?: number;
}

const LoaderPopup = ({
process,
onSuccess,
onError,
start = false,
disabled = false,
timeout = 100,
}: LoaderPopupProps) => {
const navigation = useNavigation();
const I18n = useTranslator();
const Colors = useThemeColor();
const {setActivityIndicator} = useConfig();

const [showPopup, setShowPopup] = useState<boolean>(false);

const timeoutRef = useRef(null);

const {processItem, loading} = useLoaderListner(
{
disabled,
process,
onSuccess,
onError,
},
() => setShowPopup(false),
);

const handleNotifyMe = useCallback(() => {
processProvider.notifyMe(processItem);
navigation.goBack();
}, [processItem, navigation]);

useEffect(() => {
if (start) {
processProvider.runProcess(processItem, I18n);
}
}, [start, processItem, I18n]);

useEffect(() => {
if (loading) {
if (!showPopup) {
setActivityIndicator(true);
}

timeoutRef.current = setTimeout(() => {
setActivityIndicator(false);
setShowPopup(true);
}, timeout);
}

return () => {
setActivityIndicator(false);
clearTimeout(timeoutRef.current);
};
}, [timeout, loading, showPopup, setActivityIndicator, setShowPopup]);

if (!loading || !showPopup) {
return null;
}

return (
<BlockInteractionScreen hideHeader={true}>
<Card style={styles.popupContainer}>
<Label
type="danger"
message={I18n.t('Base_Loader_DoNotCloseTheApp')}
iconName="exclamation-triangle-fill"
/>
<View style={styles.activityIndicatorContainer}>
<ActivityIndicator
size="large"
color={Colors.primaryColor.background}
/>
<Text writingType="title">
{I18n.t('Base_Loader_LoadingInProgress')}
</Text>
</View>
<Button
iconName="check-lg"
title={I18n.t('Base_Loader_NotifyMe')}
onPress={handleNotifyMe}
/>
</Card>
</BlockInteractionScreen>
);
};

const styles = StyleSheet.create({
popupContainer: {
position: 'absolute',
width: Dimensions.get('window').width * 0.9,
top: Dimensions.get('window').height * 0.2,
left: Dimensions.get('window').width * 0.05,
paddingRight: null,
paddingHorizontal: 15,
paddingVertical: 10,
},
activityIndicatorContainer: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-evenly',
alignItems: 'center',
marginVertical: 15,
},
});

export default LoaderPopup;
173 changes: 173 additions & 0 deletions packages/core/src/components/templates/Loader/ProcessProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Axelor Business Solutions
*
* Copyright (C) 2024 Axelor (<http://axelor.com>).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import {TranslatorProps} from '../../../i18n';
import {showToastMessage} from '../../../utils/show-toast-message';
import {
ProcessOption,
ProcessStatus,
ProcessItem,
callBack,
EventType,
Event,
} from './types';

class ProcessProvider {
private _events: Map<string, Event>;
private _processMap: Map<string, ProcessItem>;

constructor() {
this._events = new Map();
this._processMap = new Map();
}

on(key: string, e: EventType, c: callBack) {
const event = this._events.has(key) ? this._events.get(key) : ({} as Event);

if (!event[e]) {
event[e] = [];
}

event[e].push(c);

this._events.set(key, event);
}

private emit(key: string, e: EventType, ...args) {
if (this._events.has(key) && this._events.get(key)[e]) {
this._events.get(key)[e].forEach(c => c(...args));
}
}

registerProcess(key: string, processOptions: ProcessOption): ProcessItem {
const _p = {
key,
...processOptions,
loading: false,
notifyMe: false,
message: null,
status: null,
completed: false,
};

this._processMap.set(key, _p);

return _p;
}

getProcess(key: string): ProcessItem {
if (!this._processMap.has(key)) {
console.error(`Process with key ${key} not found.`);
return null;
}

return this._processMap.get(key);
}

notifyMe(p: ProcessItem) {
this._processMap.set(p.key, {...p, notifyMe: true});
}

async runProcess(p: ProcessItem, I18n: TranslatorProps) {
if (!this._processMap.has(p.key)) {
throw new Error(`Process with key ${p.key} not found.`);
}

this.onStart(p);
this.executeProcess(p, I18n);
}

private onFinish(
p: ProcessItem,
status: ProcessStatus,
message: string,
I18n: TranslatorProps,
) {
this._processMap.set(p.key, {
...p,
status,
loading: false,
message: message,
completed: true,
});

this.emit(
p.key,
status === ProcessStatus.COMPLETED
? EventType.COMPLETED
: EventType.FAILED,
p,
I18n,
);
}

private onCompleted(p: ProcessItem, I18n: TranslatorProps) {
const {notifyMe, message, disabled, onSuccess} = p;
if (!notifyMe) {
onSuccess();
} else {
showToastMessage({
type: 'success',
position: 'top',
topOffset: 30,
text1: I18n.t('Base_Success'),
text2: message || I18n.t('Base_Loader_ProccessSuccessMessage'),
onPress: () => !disabled && onSuccess(),
});
}
}

private onFailed(p: ProcessItem, I18n: TranslatorProps) {
const {notifyMe, message, disabled, onError} = p;
if (!notifyMe) {
onError();
} else {
showToastMessage({
type: 'error',
position: 'top',
topOffset: 30,
text1: I18n.t('Base_Error'),
text2: message || I18n.t('Base_Loader_ProccessErrorMessage'),
onPress: () => !disabled && onError(),
});
}
}

private onStart(p: ProcessItem) {
this._processMap.set(p.key, {
...p,
loading: true,
status: ProcessStatus.RUNNING,
});

this.on(p.key, EventType.COMPLETED, this.onCompleted);
this.on(p.key, EventType.FAILED, this.onFailed);
this.emit(p.key, EventType.STARTED);
}

private async executeProcess(p: ProcessItem, I18n: TranslatorProps) {
try {
const response = await p.process();
this.onFinish(p, ProcessStatus.COMPLETED, response, I18n);
} catch (error) {
this.onFinish(p, ProcessStatus.FAILED, error, I18n);
}
}
}

export const processProvider = new ProcessProvider();
3 changes: 3 additions & 0 deletions packages/core/src/components/templates/Loader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

export {default as LoaderPopup} from './LoaderPopup';
export * from './ProcessProvider';
export * from './types';
export {default as useLoaderListner} from './use-loader-listener';
Loading

0 comments on commit e12bd1e

Please sign in to comment.