## Reference Web Application EGYM provides a sample web application that might be used as a reference: https://github.com/egym/mwa-reference The purpose of the app is to give an example of how a micro web application can be structured and what mechanics are used to interact with native mobile features. EGYM Recommends creating your own web app from scratch but fetching some ideas from the `mwa-reference` app. ## Basic MWA configuration First of all, go through the [Ionic documenation](https://ionicframework.com/docs/) and get familiar with basic concepts. Further down we describe the creation process of an MWA from the EGYM point of view and in relation to the EGYM BMA app perspective. ### Ionic CLI Ionic CLI has a set of useful tools for developing Ionic apps. Install it with the following command: `npm install -g @ionic/cli` ### @capacitor-community/http In the [mwa-reference](https://github.com/egym/mwa-reference) app there is a `@capacitor-community/http` package. It is added there only for checking the backward compatibility when updating to `@capacitor/core@^4.6`. In your project you should remove this http plugin so it will not pollute your console with extra warnings. ### Create MWA skeleton Once Ionic CLI is installed, it is possible now to bootstrap a new Ionic MWA from scratch. Run: `ionic start` Follow the command line prompts to finish the installation: - Select "React" framework - Type the name of the app - Select a starter template. We recommend choosing a blank app so there are no redundant files that are usually forgotten to remove from the project. ### Linters We highly recommend to setup eslint and prettier for every web project. It helps maintain consistency in the code style and avoid general errors and therefore eliminate bugs. Here is how the eslint & prettier configuration looks like in EGYM for MWA: - Install the following packages: ```sh npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-airbnb eslint-config-airbnb-typescript eslint-config-prettier eslint-plugin-import eslint-plugin-jest eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks husky prettier pretty-quick ``` - Add [.eslintrc.js](https://github.com/egym/mwa-reference/blob/main/.eslintrc.js) and [.eslintignore](https://github.com/egym/mwa-reference/blob/main/.eslintignore) files - Add npm scripts in [package.json](https://github.com/egym/mwa-reference/blob/main/package.json) ```json "lint": "eslint --ext js,jsx,ts,tsx src", "lint-fix": "eslint --fix --ext js,jsx,ts,tsx src", ``` - We also recommend to add the "clear npm cache" npm script, which sometimes can help with clearing eslint errors which are already resolved but still shown as errors: ```json "nuke:cache": "rm -rf ./node_modules/.cache" ``` - Add prettier files. [.prettierrc.json](https://github.com/egym/mwa-reference/blob/main/.prettierrc.json) and [.prettierignore](https://github.com/egym/mwa-reference/blob/main/.prettierignore) - Install pre-commit hooks. Add the [.husky](https://github.com/egym/mwa-reference/tree/main/.husky) folder with pre-commit scripts & run `husky install`. Pre-commit hook will run prettier checks on staged files. ## Communication between mobile app and MWA ### HTTP In the MWA it is possible to make HTTP calls with the browser [fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and it will work well until the backend restricts access to its resources by CORS policies. By default the MWA runs on http://localhost (Android) or capacitor://localhost (iOS) and usually backends don't allow to make http calls from any origins if it's not specifically configured. Capacitor allows to change the hostnames with the following capacitor.config.ts config: ```json "server": { "hostname": "my.testbackend.com", "androidScheme": "https", "iosScheme": "https", }, ``` This way the MWA can run on different origins. The backend has to also update its CORS config with the corresponding hostname (in this case `https://my.testbackend.com`). A downside of this approach is that you can't change the CORS configuration of the backend that you can't control. The most convenient way to bypass CORS is to enable the Capacitor HTTP and Cookies plugins which will allow you to make calls right from the device (not from a browser). See [capacitor.config.ts](https://github.com/egym/mwa-reference/blob/main/capacitor.config.ts): ```ts plugins: { CapacitorHttp: { enabled: true, }, CapacitorCookies: { enabled: true, }, }, ``` When this is enabled it is possible to use `CapacitorHttp` and `CapacitorCookies` from the `@capacitor/core` package. There you find the unified helper method for making http calls demonstrated in an example of the mentioned plugins usage: [createApiRequest.ts](https://github.com/egym/mwa-reference/blob/main/src/utils/api/createApiRequest.ts) (Look for `CapacitorHttp.request(...)` and `CapacitorCookies.getCookies(...)`) See the [mwa-reference](https://github.com/egym/mwa-reference/tree/main/src/pages/TestCors) example of how to bypass CORS restrictions and also an example of how to make http requests using CapacitorHttp, react-query and react hooks. ![image.png](/assets/image-5.f52a2b8d1c680b40701b5196039371c8a9373af050210278ec6189bb1b3ec7ed.469b1fe1.png) More reading: - https://ionicframework.com/docs/v5/troubleshooting/cors - https://capacitorjs.com/docs/apis/http - https://capacitorjs.com/docs/apis/cookies ### Initial Context In the [mwa-reference](https://github.com/egym/mwa-reference) you find an example of how to receive and store initial context passed from the native app. See [src/index.ts](https://github.com/egym/mwa-reference/blob/main/src/index.tsx): ```ts const initialContext = getInitialContext()?.value || ({ startingRoute: '/home', authToken: '', language: 'de_DE', lightPrimaryColor: '#ebfafc', primaryColor: '#00c4dc', primaryTextColor: '#ffffff', url: 'https://mwa-test-be.herokuapp.com', } as PortalsContext); ``` `getInitialContext` is the function from `@ionic/portals` package. When `getInitialContext` is invoked in the capacitor environment, it returns the initial context that the mobile app provides the web app on launch. When the MWA runs in the browser (e.g. during development), `getInitialContext` function will not return anything because there is no mobile app that passes initial context into MWA. That's why we have to fallback to the default initial context object. The initialContext then is passed into `` which makes the data available globally inside the app. ```tsx ``` In the following you can see how to get portalsContext data in the app inside any component or hook: ```ts const [startingRoute] = useStore(getStartingRouteSelector); ``` See how store and selectors are implemented here - https://github.com/egym/mwa-reference/tree/main/src/store See an example of how initial context data can be used - [useTestAuth.ts](https://github.com/egym/mwa-reference/blob/main/src/pages/TestAuth/hooks/useTestAuth.ts) Or see an example of initialContext's authToken usage inside real production MWA application: ```ts const useAuth = () => { const [authToken, set] = useStore(getAuthTokenSelector); const [backendUrl] = useStore(getUrlSelector); const [selectedLocationsIds] = useStore(getSelectedLocationsIds); const authQuery = useQuery( queryKeys.auth.session(String(authToken)), async () => { console.log('[WEB] - useAuth - cleanAllCookies'); await CapacitorCookies.clearAllCookies(); return authorizeUser({ payload: { egym: { token: authToken } } }); }, { onSuccess: (result) => { const token = result.data.identity?.token; const club = result.data.club; const user = result.data.user; if (token) { CapacitorCookies.setCookie({ key: 'token', value: token, url: backendUrl, }); } if (club) { Settings.defaultZone = club.time_zone; } if (user) { // set location ids only during first time fetch, ignore on refresh token if (!selectedLocationsIds?.length || !selectedLocationsIds.includes(user.home_location_id)) { console.log( `[WEB] - set user.home_location_id - ${user.home_location_id}, selectedDate - ${DateTime.now().toISO()}` ); set({ location_id: [user.home_location_id], selectedDate: DateTime.now() }); } } }, enabled: Boolean(authToken), retryOnMount: false, keepPreviousData: false, staleTime: 0, } ); return { authQuery, }; }; ``` More reading: - [Portals Initial Context](/mwa/docs/architecture#portals-initial-context) ### Publish/subscribe Thanks to Ionic Portals and Capacitor under the hood we are able to exchange messages with the mobile app. These are the [commands](/mwa/docs/architecture#supported-commands) that EGYM Android and iOS team implemented specially for MWA communication. Let's see how to use them: - `dismiss` - Request the native app to close the MWA screen. In the MWA example it is used in the very first root page where MWA opened inside the mobile app. See [`<CommonPageHeader />`](https://github.com/egym/mwa-reference/blob/main/src/components/CommonPageHeader/CommonPageHeader.tsx) component, look for `Exit MWA` button and see `publishDismiss` click handler which publishes `subscription` topic with `dismiss` type. When the mobile app receives this message it closes the current MWA and returns to the page where it was requested to open. - `authToken` - Request the authentication token for the app user. Initially the `authToken` provided through the initial context on the first app launch. But this token has a ttl and when it expires it is no longer possible to use it, that is why we need a mechanism to get refreshed a token. See [queryClient default options](https://github.com/egym/mwa-reference/blob/main/src/index.tsx#L31). Once BE api returns 401 error, retry function calls the native app with `requestAuthToken()` to refetch a new token. When the mobile app receives an `authToken` topic message, it fetches the new token from the EGYM backend and makes an `authToken` publish request to the MWA with the new token provided. To receive a new message with a new token the MWA subscribes to the `authToken` topic and listens to all messages with that topic from the app. Once it receives a new message it sets a new token to the store, so new data is available globally in the app. See how the pub-sub mechanism is implemented [here](https://github.com/egym/mwa-reference/tree/main/src/pages/TestAuth) and [here](https://github.com/egym/mwa-reference/tree/main/src/hooks/usePortalsSubscriptions). - `exerciserInfo` - Request exerciser profile data. In order to receive currently logged in user details there is mechanism same as with the `authToken` - Request message with `exerciserInfo` topic. The mobile app then returns the fetched exerciser info back into the MWA, then the MWA should catch it in the subscription. See how the pub-sub mechanism is implemented [here](https://github.com/egym/mwa-reference/tree/main/src/pages/TestAuth) and [here](https://github.com/egym/mwa-reference/tree/main/src/hooks/usePortalsSubscriptions). - `openFeature` - Navigate to the web app feature by tapping on a widget. See the example of this functionality inside the [ClassesWidget](https://github.com/egym/mwa-reference/tree/main/src/pages/Classes/ClassesWidget). It is needed to jump from one MWA widget that occupies a small part of the UI inside the mobile app into another full page MWA. To support this behavior the MWA should implement `startingRoute` property handling from initialContext. See the [src/Layout.tsx](https://github.com/egym/mwa-reference/blob/main/src/Layout.tsx) component and how `startingRoute` prop is used to route redirect: ```tsx } exact={true} /> ``` The image below demonstrates the `openFeature` use case. On the image at the left there is a classe widget rendered as MWA, a click on a particular class item navigates to full page MWA. Using regular react router navigation will not work here, because of the MWA only a small peace of UI is allocated on the native view and opening the page that is intended to be used as a full screen page will be rendered in the same limited view, that's why the `openFeature` command is needed. ![Screenshot 2022-02-28 at 12.28.43.png](/assets/screenshot-2022-02-28-at-12.28.43.bff6d34e8e2f0544041d0c0bc4ce454d8b838c562f4da3949dff6311f58acfd4.469b1fe1.png) ### Error handling Mobile team has implemented the read-only `error` topic, meaning MWA can't push messages to this topic but only subsribe to it and receive possible errors from native app. The error can occur for example when the MWA sends a new `authToken` command, then the native app receives it but when fetching new token from the backend it receives 500 or any other error - in this case instead of sending error back to the MWA in the `authToken` topic, the native app uses `error` topic and sends error in a generic format as described [here](/mwa/docs/architecture#supported-commands) - `type` attribute will identify the failed command, optional `code`, `url` and `message` fields will be populated depending on a command and provide details of failed request. See an example of how `error` subcription implemented in the reference MWA in the [usePortalsSubscriptions](https://github.com/egym/mwa-reference/blob/main/src/hooks/usePortalsSubscriptions/usePortalsSubscriptions.ts) hook: ```ts await Portals.subscribe({ topic: 'error' }, ({ topic, data }) => { console.log('[WEB] - subscription - error', topic, data); const error = parseJson(data) as PortalsError; presentAlert({ header: t('errors.oops'), subHeader: error.type, message: error.code || error.message || error.url ? `${error.code} ${error.message} ${error.url}` : undefined, buttons: [{ text: 'Refresh', handler: () => window.location.reload() }], backdropDismiss: false, translucent: true, animated: true, }); }); ``` Here it only shows an error alert with `Refresh` app button. But handling of the error can vary in different MWAs and in different cases depending on the error type, for example, you can redirect back to another page, or exit the MWA, or present the user some fancy error page, etc. ## Application Structure Notice all pages in the [mwa-reference](https://github.com/egym/mwa-reference) app have the same structure: ```tsx // optional // Page header title, back btn or smth. See component // always required // Your content of the page/widget. May consist of ionic's framework components, or your own. // optional // Content that should always be stick to the bottom, e.g. "Book class" button on the ClassDetails.tsx page ``` Following this structure ensures that the MWA app will display all elements inside the mobile app correctly. Using `IonHeader` and `IonFooter` with the `IonToolbar` applies a proper safe area indent on the top and at the bottom of the mobile app screen (if MWA is opened in full screen mode). More reading: - https://ionicframework.com/docs/layout/structure ### Safe area Capacitor provides the web app with additional css variables : ```css --ion-safe-area-top --ion-safe-area-right --ion-safe-area-bottom --ion-safe-area-left ``` Using these variables proper indents are set out of the box. They are also available in the custom SCSS code for the cases when, for example, you don't have an IonFooter on the page, but still want to set a proper indent to the bottom of the page, so the content is always properly visible and e.g. the iOS home button does not overlap. See the example below: The green area on the top is a padding-top set on the IonToolbar component: ```css padding-top: var(--ion-safe-area-top, 0) ``` The padding is applied automatically, because the app is running inside the Capacitor environment: ![image.png](/assets/image-6.a9a0c08f110043eae37e863d993077f2493426e0d07024935d60290b98ab2fa2.469b1fe1.png) If we disable this `padding-top` property for demonstration then the `Exit MWA` button will be shifted up and will stay under the iOS system time display: ![image.png](/assets/image-9.a8ba2a250a6427590a994f1a48219e524414aa69513b3b70b6a338beb55733bf.469b1fe1.png) More reading: - https://ionicframework.com/docs/theming/advanced#application-variables ## Localisation The native app passes a `language` property to the MWA on app launch. The MWA has to use it to fetch the fitting locales files and apply correct date formatting. See [src/i18n.ts](https://github.com/egym/mwa-reference/blob/main/src/i18n.ts) implementation of the i18next for strings translations. Attention there is a custom language detector: ```ts languageDetector.addDetector({ name: 'portalsInitialContext', lookup() { return window.portalsContext?.language || 'en-US'; }, }); ``` We recommend using the [Luxon](https://github.com/moment/luxon/) library for working with dates and times in JS. It provides an easy way for date manipulation and formatting for different locales. It is also important to handle the difference in date & time formatting for different countries, e.g. `5/11/09 (May 11, 2009)` (American) vs `11/5/09 (11 May 2009)` (British). Luxon takes care of this and makes formatting very easy. See how the Initial Context's `language` property is used to set which language to use on formatting: [src/index.ts](https://github.com/egym/mwa-reference/blob/main/src/index.tsx): ```ts initialContext.language = initialContext.language.replace('_', '-'); Settings.defaultLocale = initialContext.language; ``` ## Theming To make the MWA reuse the same color scheme and look as similar as possible to the native, the mobile app passes the following color props inside initial context. Each brand has its own color values. ```json lightPrimaryColor: '#ebfafc', primaryColor: '#00c4dc', primaryTextColor: '#ffffff', ``` In order to apply it, the MWA should set the received colors props to the predefined ionic's css variables - [src/index.ts (setThemeColors(window.portalsContext))](https://github.com/egym/mwa-reference/blob/main/src/index.tsx#L49) ```ts export const setThemeColors = (context: PortalsContext) => { const primaryColor = new Color(context.primaryColor); const textColor = new Color(context.primaryTextColor); document.body.style.setProperty('--ion-color-primary', primaryColor.hex); document.body.style.setProperty('--ion-color-primary-rgb', primaryColor.rgbString); document.body.style.setProperty('--ion-color-primary-contrast', textColor.hex); document.body.style.setProperty('--ion-color-primary-contrast-rgb', textColor.rgbString); document.body.style.setProperty('--ion-color-primary-shade', primaryColor.shade(0.12).hex); document.body.style.setProperty('--ion-color-primary-tint', context.lightPrimaryColor); }; ``` Or you can set it to any other custom css variable and reuse it when applying styles to custom components and overriding ionic's components props. The EGYM BMA app currently supports only light mode so you don't need to care about adopting colors for dark mode. Ionic provides variables that exist at the component level, such as --background and --color. For a list of the custom properties that a component accepts, view the CSS Custom Properties section of its API reference. For example, see the [Button CSS Custom Properties](https://ionicframework.com/docs/api/button#css-custom-properties). More reading: - https://ionicframework.com/docs/theming/basics - https://ionicframework.com/docs/theming/themes - https://ionicframework.com/docs/theming/css-variables#ionic-variables ## Cache & minimize number of loading screens We should ensure that the MWA app behavior is as snappy as possible by minimizing the number of loading screens. Every http response should be cached so when an end user enters the page they have already visited, for the second time they will not see the loading screen again. EGYM recommends using the [react-query](https://react-query-v3.tanstack.com/) library which makes it easy to cache and invalidate or replace cache data. See examples of how it could be used: - [useClassById](https://github.com/egym/mwa-reference/blob/main/src/hooks/useClassById/useClassById.ts) - [useClasses](https://github.com/egym/mwa-reference/blob/main/src/hooks/useClasses/useClasses.ts) - [useTestCors](https://github.com/egym/mwa-reference/blob/main/src/hooks/useTestCors/useTestCors.ts) To easily manage query cache keys, there is the [queryKeys factory](https://github.com/egym/mwa-reference/blob/main/src/utils/queryKeys/index.ts). You can reuse it for the cases when you need to revalidate or remove stored cache by a specific key. There are also some alternatives to react-query, such as [swr](https://github.com/vercel/swr) or [rtk-query](https://redux-toolkit.js.org/rtk-query/overview). You can see a comparison here https://react-query-v3.tanstack.com/comparison. ## Building & Running & Debugging This is how we recommend to approach the MWA development ### Locally in the computer browser As long as you don't need to perform any mobile app related checks you can run your app simply with `npm start`, it will be opened in a browser. Then you have to toggle the device toolbar and select the needed device in the `Dimensions` dropdown. This is the easiest way on how to develop an MWA without native specifications. ![image.png](/assets/image.21bd8cff526949dba4a5a1b333da1f25f8611bd3d1c85f033fdf810c12e8f3c3.469b1fe1.png) ### Capacitor app with Ionic CLI If you need to check any native app stuff, like safe-area or HTTP CORS or Cookies handling, etc, without the EGYM BMA app context (excluding [commands](#publishsubscribe)), you can run iOS or Android simulator locally with help of Ionic CLI. Run the following: ```sh ionic cap add ios ``` You will see the new `ios` folder added in the root of the project. If you are running Mac with M1 Chip you may get ruby error which relates to Cocoapods. Try [Removing Cocoapods](https://superuser.com/a/686319/372385) and then [installing it from Homebrew](https://formulae.brew.sh/formula/cocoapods). Once `ionic cap add ios` invoked successfully, run next: ```sh npm run build && ionic cap sync ionic cap run ios -l --external ``` Wait till capacitor will create and run xcode build and open a simulator: ![image.png](/assets/image-10.211d591e56ca35cef015be77cb28746c1e2c716572f6fae848bfd4ba98285a50.469b1fe1.png) You can do the same with `android`, just replace `ios` with `android` in commands above. See the guide on how to debug and see logs from the webview opened inside simulator see these sections: - [Debug MWA inside Android build](#debug-mwa-inside-android-build) - [Debug MWA inside iOS build](#debug-mwa-inside-ios-build) More reading: - https://ionicframework.com/docs/troubleshooting/debugging ### Full integration in Android or iOS build Otherwise, if you need to check the full integration with the EGYM BMA app including [commands](#publishsubscribe) and receiving a real initial context, then you need to ask the EGYM team for creating you configured iOS or Android build. Then you can run it either on your computer in a simulator or install it on your phone. Native builds will be connected to the specified MWA from the Appflow dashboard, so you can autonomously make changes in the MWA, commit & push changes, make web builds and deploy them to the specified channel. Afterwards Ionic Portals Live update will push the new build right onto the device and display the changes. ### Run and debug Android build provided by the EGYM team on local machine 1. Download Android studio, add virtual device and run a simulator. 2. Get Android build (.apk file) from the EGYM team and install it on the simulator. 3. Follow [Debug MWA inside Android build](#debug-mwa-inside-android-build) section to debug MWA inside provided build. More reading: - https://ionicframework.com/docs/troubleshooting/debugging ### Run and debug iOS build provided by the EGYM team on local machine Debugging iOS builds has some limitations: - Debugging iOS webviews requires Safari, so your dev computer must be running macOS. - You can only debug webviews in applications loaded onto your device through Xcode. You can't debug webviews in apps installed through the App Store or Apple Configurator. So the only way to debug iOS builds provided by the EGYM team is to install it to any Apple device (either MacOS or iOS) and debug changes by pushing new commits and making new builds in Appflow dashboard. You can use browser [alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert) functions to log needed info. Logs will be rendered right on the screen. Since alert only accepts strings, to display object information you can use the following: ```ts alert(JSON.stringify(someData, null, 2)) ``` ### Debug MWA inside Android build Once you have Android app with MWA opened inside simulator up and running on your computer, you can easily debug your MWA with chrome devtools. To do so open Google Chrome browser and enter `chrome://inspect` url. You will see the list of webviews opened inside the Android app simulator. Click "inspect" under the one that is not "hidden at" and use opened Chrome Dev Tools window to inspect the logs and see native Portals and Capacitor requests. | 1 | 2 | | --- | --- | | ![Screenshot 2023-01-18 at 19.28.29.png](/assets/screenshot-2023-01-18-at-19.28.29.ac6e448682244abc94b7da89136971302ace610e643f326150712b8504266e5f.469b1fe1.png) | ![Screenshot 2023-01-18 at 21.29.23.png](/assets/screenshot-2023-01-18-at-21.29.23.ab694b34783b114e9357dd08332c1efbaa3cb2cf53a5e6b232ae39159b9df8cf.469b1fe1.png) | ### Debug MWA inside iOS build If you have running iOS build that is generated from Xcode on your computer ([Capacitor app with Ionic CLI](#capacitor-app-with-ionic-cli) case) you can use Safari Web Inspector to see all logs and http requests. 1. Enable developer tools in Safari on your dev computer. Launch Safari on your dev machine and navigate to `Safari > Settings` in the menu bar. In the preferences pane that appears, click on the `Advanced` tab and then enable the `Show Develop menu in menu bar` option at the bottom. 2. Open Xcode build 3. In Safari on your dev computer, click on `Develop` in the menu bar and hover over the dropdown option that is your iOS device's name to show a list of MWA instances running on your Xcode build. 4. Click the dropdown option for the webview that you wish to debug. This will open a new Safari Web Inspector window. ![image.png](/assets/image-11.0c9f142ead6802565192b8d84e5a0d4797bd2297a774828858c685288b593e62.469b1fe1.png)