From 9af48b1065fc8ed4e0e181a04d01b7fed2fcd214 Mon Sep 17 00:00:00 2001
From: hel-axelor <101327380+hel-axelor@users.noreply.github.com>
Date: Fri, 23 Feb 2024 11:57:02 +0100
Subject: [PATCH] feat: add loader wait management popup (#367)
* RM#73990
---
.../stock/src/screens/loader/LoaderScreen.js | 43 +++--
.../templates/Loader/LoaderPopup.tsx | 150 +++++++++++++++
.../templates/Loader/ProcessProvider.ts | 173 ++++++++++++++++++
.../src/components/templates/Loader/index.ts | 3 +
.../templates/Loader/loader-helper.ts | 21 +++
.../src/components/templates/Loader/types.ts | 51 ++++++
.../templates/Loader/use-loader-listener.ts | 87 ++++-----
packages/core/src/i18n/translations/en.json | 3 +
packages/core/src/i18n/translations/fr.json | 3 +
9 files changed, 466 insertions(+), 68 deletions(-)
create mode 100644 packages/core/src/components/templates/Loader/LoaderPopup.tsx
create mode 100644 packages/core/src/components/templates/Loader/ProcessProvider.ts
create mode 100644 packages/core/src/components/templates/Loader/loader-helper.ts
create mode 100644 packages/core/src/components/templates/Loader/types.ts
diff --git a/packages/apps/stock/src/screens/loader/LoaderScreen.js b/packages/apps/stock/src/screens/loader/LoaderScreen.js
index f187147e8b..567148e7cf 100644
--- a/packages/apps/stock/src/screens/loader/LoaderScreen.js
+++ b/packages/apps/stock/src/screens/loader/LoaderScreen.js
@@ -34,34 +34,49 @@
* along with this program. If not, see .
*/
-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 (
-
+
);
diff --git a/packages/core/src/components/templates/Loader/LoaderPopup.tsx b/packages/core/src/components/templates/Loader/LoaderPopup.tsx
new file mode 100644
index 0000000000..41466aa5d7
--- /dev/null
+++ b/packages/core/src/components/templates/Loader/LoaderPopup.tsx
@@ -0,0 +1,150 @@
+/*
+ * Axelor Business Solutions
+ *
+ * Copyright (C) 2024 Axelor ().
+ *
+ * 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 .
+ */
+
+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;
+ 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(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 (
+
+
+
+
+
+
+ {I18n.t('Base_Loader_LoadingInProgress')}
+
+
+
+
+
+ );
+};
+
+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;
diff --git a/packages/core/src/components/templates/Loader/ProcessProvider.ts b/packages/core/src/components/templates/Loader/ProcessProvider.ts
new file mode 100644
index 0000000000..5c7b571ddc
--- /dev/null
+++ b/packages/core/src/components/templates/Loader/ProcessProvider.ts
@@ -0,0 +1,173 @@
+/*
+ * Axelor Business Solutions
+ *
+ * Copyright (C) 2024 Axelor ().
+ *
+ * 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 .
+ */
+
+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;
+ private _processMap: Map;
+
+ 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();
diff --git a/packages/core/src/components/templates/Loader/index.ts b/packages/core/src/components/templates/Loader/index.ts
index b23ccd9918..6a54e97a96 100644
--- a/packages/core/src/components/templates/Loader/index.ts
+++ b/packages/core/src/components/templates/Loader/index.ts
@@ -16,4 +16,7 @@
* along with this program. If not, see .
*/
+export {default as LoaderPopup} from './LoaderPopup';
+export * from './ProcessProvider';
+export * from './types';
export {default as useLoaderListner} from './use-loader-listener';
diff --git a/packages/core/src/components/templates/Loader/loader-helper.ts b/packages/core/src/components/templates/Loader/loader-helper.ts
new file mode 100644
index 0000000000..489d5ff01f
--- /dev/null
+++ b/packages/core/src/components/templates/Loader/loader-helper.ts
@@ -0,0 +1,21 @@
+/*
+ * Axelor Business Solutions
+ *
+ * Copyright (C) 2024 Axelor ().
+ *
+ * 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 .
+ */
+
+export const generateUniqueID = () => {
+ return new Date().getTime().toString();
+};
diff --git a/packages/core/src/components/templates/Loader/types.ts b/packages/core/src/components/templates/Loader/types.ts
new file mode 100644
index 0000000000..de306ac4f2
--- /dev/null
+++ b/packages/core/src/components/templates/Loader/types.ts
@@ -0,0 +1,51 @@
+/*
+ * Axelor Business Solutions
+ *
+ * Copyright (C) 2024 Axelor ().
+ *
+ * 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 .
+ */
+
+export type callBack = (...args) => void;
+
+export enum EventType {
+ STARTED = 'STARTED',
+ COMPLETED = 'COMPLETED',
+ FAILED = 'FAILED',
+}
+
+export type Event = {
+ [e in EventType]: callBack[];
+};
+
+export enum ProcessStatus {
+ RUNNING = 'RUNNING',
+ COMPLETED = 'COMPLETED',
+ FAILED = 'FAILED',
+}
+
+export type ProcessOption = {
+ disabled: boolean;
+ process: () => Promise;
+ onSuccess: () => void;
+ onError: () => void;
+};
+
+export type ProcessItem = ProcessOption & {
+ key: string;
+ loading: boolean;
+ notifyMe: boolean;
+ status: ProcessStatus;
+ message: string;
+ completed: boolean;
+};
diff --git a/packages/core/src/components/templates/Loader/use-loader-listener.ts b/packages/core/src/components/templates/Loader/use-loader-listener.ts
index bca20768c0..6e03f8d974 100644
--- a/packages/core/src/components/templates/Loader/use-loader-listener.ts
+++ b/packages/core/src/components/templates/Loader/use-loader-listener.ts
@@ -16,62 +16,41 @@
* along with this program. If not, see .
*/
-import {useCallback, useEffect, useMemo, useState} from 'react';
-import {useTranslator} from '../../../i18n';
-import {showToastMessage} from '../../../utils/show-toast-message';
-
-interface LoaderListenerProps {
- process: () => Promise;
- onSuccess?: () => void;
- onError?: () => void;
-}
-
-const useLoaderListner = ({
- process,
- onSuccess = () => {},
- onError = () => {},
-}: LoaderListenerProps) => {
- const I18n = useTranslator();
-
- const [loading, setLoading] = useState(false);
- const [start, setStart] = useState(false);
-
- const executeProcess = useCallback(async () => {
- try {
- setStart(false);
- setLoading(true);
-
- const response = await process();
-
- showToastMessage({
- type: 'success',
- position: 'top',
- topOffset: 30,
- text1: I18n.t('Base_Success'),
- text2: response || I18n.t('Base_Loader_ProccessSuccessMessage'),
- onPress: onSuccess,
- });
- } catch (error) {
- showToastMessage({
- type: 'error',
- position: 'top',
- topOffset: 30,
- text1: I18n.t('Base_Error'),
- text2: error || I18n.t('Base_Loader_ProccessErrorMessage'),
- onPress: onError,
- });
- } finally {
- setLoading(false);
- }
- }, [process, onSuccess, onError, I18n]);
+import {useEffect, useMemo, useState} from 'react';
+import {EventType, ProcessItem, ProcessOption} from './types';
+import {processProvider} from './ProcessProvider';
+import {generateUniqueID} from './loader-helper';
+
+const useLoaderListner = (
+ processOptions: ProcessOption,
+ onFinish = () => {},
+) => {
+ const [key, setKey] = useState();
+ const [loading, setLoading] = useState(false);
+ const [processItem, setProcessItem] = useState();
+
+ const onFinishCallback = () => {
+ setLoading(false);
+ onFinish();
+ };
useEffect(() => {
- if (start && !loading) {
- executeProcess();
- }
- }, [start, loading, executeProcess]);
-
- return useMemo(() => ({loading, listener: () => setStart(true)}), [loading]);
+ const unid = generateUniqueID();
+ const p = processProvider.registerProcess(unid, processOptions);
+
+ processProvider.on(unid, EventType.STARTED, () => setLoading(true));
+ processProvider.on(unid, EventType.COMPLETED, onFinishCallback);
+ processProvider.on(unid, EventType.FAILED, onFinishCallback);
+
+ setKey(unid);
+ setProcessItem(p);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return useMemo(
+ () => ({key, processItem, loading}),
+ [key, processItem, loading],
+ );
};
export default useLoaderListner;
diff --git a/packages/core/src/i18n/translations/en.json b/packages/core/src/i18n/translations/en.json
index ba6cc5f495..a2136e35f2 100644
--- a/packages/core/src/i18n/translations/en.json
+++ b/packages/core/src/i18n/translations/en.json
@@ -160,6 +160,9 @@
"Base_Dashboard_RefreshConfig": "Refresh dashboard",
"Base_Loader_ProccessSuccessMessage": "Process successfully completed.",
"Base_Loader_ProccessErrorMessage": "An error has occurred.",
+ "Base_Loader_DoNotCloseTheApp": "Do not close the application until the process is done.",
+ "Base_Loader_NotifyMe": "Notify me when it's ready",
+ "Base_Loader_LoadingInProgress": "Loading in progress",
"Base_SliceAction_FetchAttachedFiles": "fetch attached files",
"Base_SliceAction_FetchFilesDetails": "fetch file details",
"Base_SliceAction_FetchMetaModule": "fetch meta modules",
diff --git a/packages/core/src/i18n/translations/fr.json b/packages/core/src/i18n/translations/fr.json
index e842807e1c..dec57ad97f 100644
--- a/packages/core/src/i18n/translations/fr.json
+++ b/packages/core/src/i18n/translations/fr.json
@@ -160,6 +160,9 @@
"Base_Dashboard_RefreshConfig": "Actualiser le tableau de bord",
"Base_Loader_ProccessSuccessMessage": "Le processus s'est terminé avec succès.",
"Base_Loader_ProccessErrorMessage": "Une erreur s'est produite.",
+ "Base_Loader_DoNotCloseTheApp": "Veuillez ne pas fermer l'application avant que le processus soit terminé.",
+ "Base_Loader_NotifyMe": "M'avertir lorsqu'il est prêt",
+ "Base_Loader_LoadingInProgress": "Chargement en cours",
"Base_SliceAction_FetchAttachedFiles": "récupération des fichiers joints",
"Base_SliceAction_FetchFilesDetails": "récupération des détails du fichier",
"Base_SliceAction_FetchMetaModule": "récupération des modules",