From da2837495f5ca7f4259d5c8abea8d5d0e145a5fd Mon Sep 17 00:00:00 2001 From: lme-axelor <102581501+lme-axelor@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:52:22 +0200 Subject: [PATCH] feat: add module draggable tools component (#732) * RM#82774 --- changelogs/unreleased/82774.json | 5 + .../en/Application/Creation_d_un_module.md | 2 + docs/doc/en/Outil/toolManagenent.md | 100 ++++++++++++++++++ .../fr/Application/Creation_d_un_module.md | 2 + docs/doc/fr/Outil/toolManagenent.md | 100 ++++++++++++++++++ .../core/src/app/ContextedApplication.tsx | 9 +- packages/core/src/app/modules/types.ts | 23 ++++ .../templates/GlobalToolBox/GlobalToolBox.tsx | 99 +++++++++++++++++ .../templates/GlobalToolBox/tool.helpers.ts | 54 ++++++++++ .../core/src/components/templates/index.js | 1 + packages/core/src/index.ts | 2 +- .../src/navigator/ActiveScreenProvider.ts | 92 ++++++++++++++++ packages/core/src/navigator/Navigator.js | 10 +- packages/core/src/navigator/index.ts | 20 ++++ 14 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 changelogs/unreleased/82774.json create mode 100644 docs/doc/en/Outil/toolManagenent.md create mode 100644 docs/doc/fr/Outil/toolManagenent.md create mode 100644 packages/core/src/components/templates/GlobalToolBox/GlobalToolBox.tsx create mode 100644 packages/core/src/components/templates/GlobalToolBox/tool.helpers.ts create mode 100644 packages/core/src/navigator/ActiveScreenProvider.ts create mode 100644 packages/core/src/navigator/index.ts diff --git a/changelogs/unreleased/82774.json b/changelogs/unreleased/82774.json new file mode 100644 index 0000000000..f418e8ea03 --- /dev/null +++ b/changelogs/unreleased/82774.json @@ -0,0 +1,5 @@ +{ + "title": "Module: add new system to manage quick actions with a global toolbox", + "type": "feat", + "packages": "core" +} diff --git a/docs/doc/en/Application/Creation_d_un_module.md b/docs/doc/en/Application/Creation_d_un_module.md index 342db162cd..1c7d51786c 100644 --- a/docs/doc/en/Application/Creation_d_un_module.md +++ b/docs/doc/en/Application/Creation_d_un_module.md @@ -50,6 +50,7 @@ export interface Module { }; requiredConfig?: string[]; moduleRegister?: Function; + globalTools?: Tool[]; } ``` @@ -68,6 +69,7 @@ A module therefore has : - a configuration of templates for API calls (_models_). - a list of web application names to retrieve the associated configuration (_requiredConfig_), such as 'AppBase' or 'AppMobileSettings'. Each configuration will then be retrieved using the application's router. The associated routes must therefore be specified to the router. New routes can be set in the application configuration file via the _additionalRoutes_ attribute. - a function for dynamically registering modules (_moduleRegister_). This function will be executed once when the user logs in, to enable the creation of menus and screens from ERP data such as dashboards or customized web views. +- a list of tools to display globally on the application (_globalTools_). # Dynamic module creation diff --git a/docs/doc/en/Outil/toolManagenent.md b/docs/doc/en/Outil/toolManagenent.md new file mode 100644 index 0000000000..9c6270dd35 --- /dev/null +++ b/docs/doc/en/Outil/toolManagenent.md @@ -0,0 +1,100 @@ +--- +id: toolManagement +sidebar_position: 10 +sidebar_class_name: icon tools +--- + +# Quick actions management + +It is sometimes necessary to add quick actions on a number of screens, but it is very costly to overload each screen to add the various tools. To simplify these additions, the application's base contains a toolbox that each module can enhance with quick actions. These actions can then be displayed on all screens according to the given configuration. + +When exported, each module can enter a list of tools in its _globalTools_ attribute: + +```tsx +interface ToolData { + dispatch: Dispatch; + storeState: any; + screenContext: any; +} + +interface ActionToolData extends ToolData { + navigation: any; +} + +export interface Tool { + key: string; + order?: number; + title?: string; + iconName: string; + color?: string; + hideIf?: (data: ToolData) => boolean; + disabledIf?: (data: ToolData) => boolean; + onPress: (data: ActionToolData) => void; +} + +export interface Module { + name: string; + ... + globalTools?: Tool[]; +} +``` + +A tool is defined with the following properties: + +- _key_: action identifier to enable overloading between modules. +- _order_: action order in the list. The action with the lowest order will appear at the top of the toolbox. +- _title_: translation key to be used for the title displayed in the toolbox next to the action. +- _iconName_ : name of the [Bootstrap](https://icons.getbootstrap.com/) icon to be displayed on the button. +- _color_ : color key to be displayed. The default color is `'primaryColor'`. +- _hideIf_ : function used to define the tool's display condition. This function takes as arguments the state of the application's store and the current screen context. +- _disabledIf_ : function for defining the tool's activation condition. This function takes as arguments the state of the application's store and the current screen context. +- _onPress_: function for defining tool behavior. This function takes as arguments the state of the store, the screen context and the dispatch & navigation tools for making an API call or navigating to a specific screen. + +Here's an example of how to define a tool in the Sales module to add a product to the user's cart: + +```tsx +export const SaleModule: Module = { + name: 'app-sale', + ... + globalTools: [ + { + key: 'sale_activeCart_addProduct', + iconName: 'cart-plus-fill', + hideIf: ({screenContext, storeState}) => { + return ( + !storeState.appConfig?.sale?.isCartManagementEnabled || + screenContext?.productId == null + ); + }, + onPress: ({dispatch, screenContext, storeState}) => { + dispatch( + addProductToUserCart({ + productId: screenContext.productId, + userId: storeState.auth?.userId, + }), + ); + }, + }, + ], +}; +``` + +To define the context of a screen, simply use the `useContextRegister` hook and pass the associated data as arguments: + +```tsx +import React from 'react'; +import {Screen} from '@axelor/aos-mobile-ui'; +import {useContextRegister} from '@axelor/aos-mobile-core'; + +const ProductDetailsScreen = ({route}) => { + const {productId} = route.params.productId; + + useContextRegister({productId}); + + ... + + return (...); +}; + +export default ProductDetailsScreen; +``` diff --git a/docs/doc/fr/Application/Creation_d_un_module.md b/docs/doc/fr/Application/Creation_d_un_module.md index adf4ce6fa3..a340a112c2 100644 --- a/docs/doc/fr/Application/Creation_d_un_module.md +++ b/docs/doc/fr/Application/Creation_d_un_module.md @@ -50,6 +50,7 @@ export interface Module { }; requiredConfig?: string[]; moduleRegister?: Function; + globalTools?: Tool[]; } ``` @@ -68,6 +69,7 @@ Un module possède donc : - une configuration de modèles pour les appels API (_models_). - une liste de noms d'application web pour récupérer la configuration associée (_requiredConfig_), comme par exemple 'AppBase" ou 'AppMobileSettings'. Chaque configuration sera ensuite récupérée avec le router de l'application. Il faut donc que les routes associées soient renseignées auprès du router. Il est possible de renseigner des nouvelles routes dans le fichier de configuration de l'application à travers l'attribut _additionalRoutes_. - une fonction pour enregistrer des modules dynamiquement (_moduleRegister_). Cette fonction sera éxécuté une seule fois à la connexion de l'utilisateur pour permettre la création de menus et écrans à partir de données de l'ERP comme les tableaux de bord ou les vues web personnalisées. +- une liste d'outils à afficher globalement sur l'application (_globalTools_). # Création dynamique de modules diff --git a/docs/doc/fr/Outil/toolManagenent.md b/docs/doc/fr/Outil/toolManagenent.md new file mode 100644 index 0000000000..b5ddec944b --- /dev/null +++ b/docs/doc/fr/Outil/toolManagenent.md @@ -0,0 +1,100 @@ +--- +id: toolManagement +sidebar_position: 10 +sidebar_class_name: icon tools +--- + +# Gestion des actions rapides + +Il est parfois nécessaire d'ajouter des actions rapides sur un certain nombre d'écrans mais cela est très coûteux de surcharger chaque écran pour ajouter les différents outils. Pour simplifier ces ajouts, la base de l'application contient donc une boîte à outils que chaque module peut venir agrémenter d'actions rapides. Ces actions pourront alors être affichées sur tous les écrans en fonction de la configuration donnée. + +Chaque module lors de son export peut venir renseigner une liste d'outils dans son attribut _globalTools_ : + +```tsx +interface ToolData { + dispatch: Dispatch; + storeState: any; + screenContext: any; +} + +interface ActionToolData extends ToolData { + navigation: any; +} + +export interface Tool { + key: string; + order?: number; + title?: string; + iconName: string; + color?: string; + hideIf?: (data: ToolData) => boolean; + disabledIf?: (data: ToolData) => boolean; + onPress: (data: ActionToolData) => void; +} + +export interface Module { + name: string; + ... + globalTools?: Tool[]; +} +``` + +Un outil est défini avec les propriétés suivantes : + +- _key_ : identifiant de l'action pour permettre la surcharge entre les modules. +- _order_ : ordre de l'action dans la liste. L'action avec l'ordre le plus bas apparaîtra au plus haut dans la boîte à outils. +- _title_ : clé de traduction à utiliser pour le titre affiché dans la boîte à outils à côté de l'action. +- _iconName_ : nom de l'icon [Bootstrap](https://icons.getbootstrap.com/) à afficher sur le bouton. +- _color_ : clé de la couleur à afficher. La couleur par défaut est `'primaryColor'`. +- _hideIf_ : fonction permettant de définir la condition d'affichage de l'outil. Cette fonction prend en arguments l'état du store de l'application et le contexte de l'écran actuel. +- _disabledIf_ : fonction permettant de définir la condition d'activation de l'outil. Cette fonction prend en arguments l'état du store de l'application et le contexte de l'écran actuel. +- _onPress_ : fonction permettant de définir le comportement de l'outil. Cette fonction prend en arguments l'état du store, le contexte de l'écran et les outils dispatch & navigation pour réaliser un appel API ou une navigation vers un écran spécifique. + +Voici un exemple de définition d'un outil dans le module Ventes pour ajouter un produit dans le panier de l'utilisateur : + +```tsx +export const SaleModule: Module = { + name: 'app-sale', + ... + globalTools: [ + { + key: 'sale_activeCart_addProduct', + iconName: 'cart-plus-fill', + hideIf: ({screenContext, storeState}) => { + return ( + !storeState.appConfig?.sale?.isCartManagementEnabled || + screenContext?.productId == null + ); + }, + onPress: ({dispatch, screenContext, storeState}) => { + dispatch( + addProductToUserCart({ + productId: screenContext.productId, + userId: storeState.auth?.userId, + }), + ); + }, + }, + ], +}; +``` + +Pour définir le contexte d'un écran, il suffit d'utiliser le hook `useContextRegister` et de transmettre les données associées en arguments : + +```tsx +import React from 'react'; +import {Screen} from '@axelor/aos-mobile-ui'; +import {useContextRegister} from '@axelor/aos-mobile-core'; + +const ProductDetailsScreen = ({route}) => { + const {productId} = route.params.productId; + + useContextRegister({productId}); + + ... + + return (...); +}; + +export default ProductDetailsScreen; +``` diff --git a/packages/core/src/app/ContextedApplication.tsx b/packages/core/src/app/ContextedApplication.tsx index f78e263bed..1b5a95ecdb 100644 --- a/packages/core/src/app/ContextedApplication.tsx +++ b/packages/core/src/app/ContextedApplication.tsx @@ -25,7 +25,13 @@ import RootNavigator from './RootNavigator'; import Translator from '../i18n/component/Translator'; import {getActiveUserInfo} from '../api/login-api'; import ErrorScreen from '../screens/ErrorScreen'; -import {Camera, HeaderBandList, Scanner, Toast} from '../components'; +import { + Camera, + GlobalToolBox, + HeaderBandList, + Scanner, + Toast, +} from '../components'; import {RouterProvider} from '../config'; import {proxy, releaseConfig, versionCheckConfig} from './types'; import {useDispatch} from '../redux/hooks'; @@ -89,6 +95,7 @@ const ContextedApplication = ({ + . */ +import {Dispatch} from 'react'; import {Reducer} from '@reduxjs/toolkit'; import {Schema} from 'yup'; import {FormConfigs} from '../../forms/types'; @@ -89,6 +90,27 @@ export interface Models { typeObjects?: ModuleSelections; } +interface ToolData { + dispatch: Dispatch; + storeState: any; + screenContext: any; +} + +interface ActionToolData extends ToolData { + navigation: any; +} + +export interface Tool { + key: string; + order?: number; + title?: string; + iconName: string; + color?: string; + hideIf?: (data: ToolData) => boolean; + disabledIf?: (data: ToolData) => boolean; + onPress: (data: ActionToolData) => void; +} + type version = `${number}.${number}.${number}` | '-'; export interface Compatibility { @@ -128,4 +150,5 @@ export interface Module { requiredConfig?: string[]; /** Function which will be executed once after user login to create modules/menus based on data */ moduleRegister?: Function; + globalTools?: Tool[]; } diff --git a/packages/core/src/components/templates/GlobalToolBox/GlobalToolBox.tsx b/packages/core/src/components/templates/GlobalToolBox/GlobalToolBox.tsx new file mode 100644 index 0000000000..f2fc2726ae --- /dev/null +++ b/packages/core/src/components/templates/GlobalToolBox/GlobalToolBox.tsx @@ -0,0 +1,99 @@ +/* + * 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, {useMemo} from 'react'; +import {StyleSheet, View} from 'react-native'; +import { + DraggableWrapper, + FloatingButton, + useThemeColor, +} from '@axelor/aos-mobile-ui'; +import {useDispatch, useSelector} from '../../../redux/hooks'; +import {useNavigation} from '../../../hooks/use-navigation'; +import {useActiveScreen} from '../../../navigator'; +import {useTranslator} from '../../../i18n'; +import {Tool, useModules} from '../../../app'; +import {addDefaultValues, addModuleTools} from './tool.helpers'; + +const GlobalToolBox = () => { + const I18n = useTranslator(); + const Colors = useThemeColor(); + const navigation = useNavigation(); + const {name, context} = useActiveScreen(); + const {modules} = useModules(); + const dispatch = useDispatch(); + + const storeState = useSelector(state => state); + + const modulesActions: Tool[] = useMemo( + () => + modules + .filter(_m => _m.globalTools?.length > 0) + .reduce(addModuleTools, []) + .map(addDefaultValues), + [modules], + ); + + const visibleActions = useMemo(() => { + const data = {dispatch, storeState, screenContext: context}; + + return modulesActions + .filter(_a => !_a.hideIf(data)) + .sort((a, b) => a.order - b.order) + .map(_a => ({ + key: _a.key, + title: _a.title, + color: Colors[_a.color], + iconName: _a.iconName, + disabled: _a.disabledIf(data), + onPress: () => _a.onPress({...data, navigation}), + })); + }, [Colors, context, dispatch, modulesActions, navigation, storeState]); + + if (name == null) { + return null; + } + + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + position: { + position: 'absolute', + bottom: 30, + right: 30, + zIndex: 9999, + }, + buttonPosition: { + position: 'relative', + bottom: undefined, + right: undefined, + }, +}); + +export default GlobalToolBox; diff --git a/packages/core/src/components/templates/GlobalToolBox/tool.helpers.ts b/packages/core/src/components/templates/GlobalToolBox/tool.helpers.ts new file mode 100644 index 0000000000..7a104e7176 --- /dev/null +++ b/packages/core/src/components/templates/GlobalToolBox/tool.helpers.ts @@ -0,0 +1,54 @@ +/* + * 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 {Module, Tool} from '../../../app'; + +export const addModuleTools = ( + registeredTools: Tool[], + module: Module, +): Tool[] => { + const currentTools = registeredTools.map(_i => _i.key); + const moduleTools = module.globalTools; + + let result: Tool[] = + moduleTools.filter(({key}) => !currentTools.includes(key)) ?? []; + + registeredTools.forEach(_tool => { + const overrideTool = moduleTools.find(({key}) => key === _tool.key); + + if (overrideTool == null) { + result.push(_tool); + } else { + result.push({ + ..._tool, + ...overrideTool, + }); + } + }); + + return result; +}; + +export const addDefaultValues = (tool: Tool, idx: number): Tool => { + return { + ...tool, + order: tool.order ?? idx * 10, + hideIf: tool.hideIf != null ? tool.hideIf : () => false, + disabledIf: tool.disabledIf != null ? tool.disabledIf : () => false, + }; +}; diff --git a/packages/core/src/components/templates/index.js b/packages/core/src/components/templates/index.js index ec8b100be2..d32b971a97 100644 --- a/packages/core/src/components/templates/index.js +++ b/packages/core/src/components/templates/index.js @@ -17,6 +17,7 @@ */ export {default as AttachedFilesView} from './AttachedFilesView/AttachedFilesView'; +export {default as GlobalToolBox} from './GlobalToolBox/GlobalToolBox'; export {default as MailMessageView} from './MailMessageView/MailMessageView'; export {default as PeriodInput} from './PeriodInput/PeriodInput'; export {default as PopupApplicationInformation} from './PopupApplicationInformation/PopupApplicationInformation'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cafca649b2..a08d88fd83 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,7 +17,7 @@ */ export * from './app'; -export {default as Navigator} from './navigator/Navigator'; +export * from './navigator'; export {configGlobalStore} from './redux/store'; export {storage, Storage, useStorage} from './storage/Storage'; export {traceError} from './api/traceback-api'; diff --git a/packages/core/src/navigator/ActiveScreenProvider.ts b/packages/core/src/navigator/ActiveScreenProvider.ts new file mode 100644 index 0000000000..c3bdb39e16 --- /dev/null +++ b/packages/core/src/navigator/ActiveScreenProvider.ts @@ -0,0 +1,92 @@ +/* + * 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 {useCallback, useEffect, useMemo, useState} from 'react'; +import {useIsFocused} from '../hooks/use-navigation'; + +class ActiveScreenProvider { + private screenName: string; + private screenContext: any; + private refreshCallBack: Function[]; + + constructor() { + this.screenName = null; + this.screenContext = null; + this.refreshCallBack = []; + } + + registerCallback(callBack: Function) { + this.refreshCallBack.push(callBack); + } + + unregisterCallback(callBack: Function) { + this.refreshCallBack = this.refreshCallBack.filter(_f => _f !== callBack); + } + + registerActiveScreen(state: any) { + this.screenName = state?.routes?.at(-1)?.name; + this.screenContext = null; + this.updateState(); + } + + registerScreenContext(context: any) { + this.screenContext = context; + this.updateState(); + } + + private updateState() { + this.refreshCallBack.forEach(_f => + _f({screenName: this.screenName, screenContext: this.screenContext}), + ); + } +} + +export const activeScreenProvider = new ActiveScreenProvider(); + +export const useActiveScreen = () => { + const [activeScreen, setActiveScreen] = useState(); + const [context, setContext] = useState(); + + const refreshData = useCallback(({screenName, screenContext}) => { + setActiveScreen(screenName); + setContext(screenContext); + }, []); + + useEffect(() => { + activeScreenProvider.registerCallback(refreshData); + + return () => { + activeScreenProvider.unregisterCallback(refreshData); + }; + }, [refreshData]); + + return useMemo( + () => ({name: activeScreen, context}), + [activeScreen, context], + ); +}; + +export const useContextRegister = (data: any) => { + const isFocused = useIsFocused(); + + useEffect(() => { + if (isFocused) { + activeScreenProvider.registerScreenContext(data); + } + }, [data, isFocused]); +}; diff --git a/packages/core/src/navigator/Navigator.js b/packages/core/src/navigator/Navigator.js index fca6404893..4b5a262842 100644 --- a/packages/core/src/navigator/Navigator.js +++ b/packages/core/src/navigator/Navigator.js @@ -52,6 +52,7 @@ import {usePermissionsFetcher} from '../permissions'; import {navigationInformations} from './NavigationInformationsProvider'; import {registerTypes} from '../selections'; import {useModulesInitialisation} from '../app'; +import {activeScreenProvider} from './ActiveScreenProvider'; const Drawer = createDrawerNavigator(); const Stack = createStackNavigator(); @@ -142,7 +143,14 @@ const Navigator = ({mainMenu, onRefresh, versionCheckConfig}) => { const ModulesScreensStackNavigator = useCallback( ({initialRouteName, ...rest}) => ( - + { + activeScreenProvider.registerActiveScreen(e.data?.state); + }, + }}> {Object.entries(modulesScreens).map( ([ key, diff --git a/packages/core/src/navigator/index.ts b/packages/core/src/navigator/index.ts new file mode 100644 index 0000000000..0c3d2474aa --- /dev/null +++ b/packages/core/src/navigator/index.ts @@ -0,0 +1,20 @@ +/* + * 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 {useActiveScreen, useContextRegister} from './ActiveScreenProvider'; +export {default as Navigator} from './Navigator';