diff --git a/.env b/.env new file mode 100644 index 0000000..863ad0a --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +JSON_PLACEHOLDER_API="https://jsonplaceholder.typicode.com" +SIMPSONS_API="https://thesimpsonsquoteapi.glitch.me/" +SENTRY_DSN= "YOU_SENTRY_DSN" +POSTHOG_KEY= 'YOUR_POSTHOG_PROJECT_KEY' \ No newline at end of file diff --git a/.eslintignore b/.eslintignore index 8fbdfa5..81a97e2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,14 @@ android/** ios/** __tests__/** **/tests/*** -.eslintrc.js \ No newline at end of file +web-build/** +.eslintrc.js +e2e/**/*.* +metrics/* +jest.setup.js +babel.config.js +reports +report.json +growthbook.js +**/tests/*.test.js +webpack.config.js \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index c5366cf..7d646b7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,16 +1,15 @@ const fs = require('fs'); const path = require('path'); - const prettierOptions = JSON.parse( - fs.readFileSync(path.resolve(__dirname, '.prettierrc'), 'utf8'), + fs.readFileSync(path.resolve(__dirname, '.prettierrc'), 'utf8') ); module.exports = { root: true, parser: 'babel-eslint', extends: [ 'airbnb', - 'prettier', + 'prettier', 'prettier/react', 'plugin:prettier/recommended', 'plugin:sonarjs/recommended', @@ -22,7 +21,6 @@ module.exports = { 'immutable', 'sonarjs', 'prettier', - 'redux-saga', 'react-native', 'react', 'react-hooks', @@ -54,13 +52,13 @@ module.exports = { 'import/no-unresolved': 0, 'import/prefer-default-export': 0, 'react/jsx-props-no-spreading': 0, - 'camelcase': ['error', { 'properties': 'always', ignoreImports: false}], + camelcase: ['error', { properties: 'always', ignoreImports: false }], indent: [ 2, 2, { - SwitchCase: 1, - }, + SwitchCase: 1 + } ], 'jsx-a11y/aria-props': 2, 'jsx-a11y/heading-has-content': 0, @@ -96,8 +94,6 @@ module.exports = { 'react/require-extension': 0, 'react/self-closing-comp': 0, 'react/sort-comp': 0, - 'redux-saga/no-yield-in-race': 2, - 'redux-saga/yield-effects': 2, 'require-yield': 0, 'react/no-array-index-key': 0, 'react/jsx-curly-newline': 0, @@ -120,7 +116,7 @@ module.exports = { } ], 'no-shadow': 'error', - complexity: ['error', 10], + complexity: ['error', 4], 'no-empty': 'error', 'import/order': [ 'error', @@ -146,7 +142,8 @@ module.exports = { } ], 'fp/no-nil': 0, - 'fp/no-unused-expression': 0 + 'fp/no-unused-expression': 0, + 'fp/no-throw': 0 }, settings: { 'import/resolver': { @@ -171,4 +168,4 @@ module.exports = { } } } -}; \ No newline at end of file +}; diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9166e58..cfc862b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,9 +1,9 @@ name: react-native-template on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master, dev] jobs: install-and-test: runs-on: ubuntu-latest @@ -13,7 +13,7 @@ jobs: run: yarn - name: Lint - run: npm run lint + run: yarn lint - name: Test and generate coverage report uses: artiomtr/jest-coverage-report-action@v2.2.9 diff --git a/.gitignore b/.gitignore index a47ba05..20eccd7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,5 @@ buck-out/ web-build/ dist/ reports -coverage \ No newline at end of file +coverage +.env.local diff --git a/App.js b/App.js index e749f55..2f4d2d2 100644 --- a/App.js +++ b/App.js @@ -4,6 +4,12 @@ import { registerRootComponent } from 'expo'; import App from '@app/app'; +import { SENTRY_DSN } from '@env'; +import * as Sentry from '@sentry/react-native'; + +Sentry.init({ + dsn: SENTRY_DSN +}); if (!window.Intl) { new Promise(resolve => { diff --git a/README.md b/README.md index e63907e..29c6081 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,10 @@

-An enterprise React Native template application showcasing - Testing strategies, Global state management, middleware support, a network layer, component library integration, localization, navigation configuration, and Continuous integration. +An enterprise React Native template application showcasing - Testing strategies, Global state management, middleware support, a network layer, component library integration, localization, navigation configuration, Continuous integration, analytics, feature flagging, and error tracking.

- ___ - +---

@@ -31,9 +30,10 @@ An enterprise React Native template application showcasing - Testing strategies, - ___ +--- + +We’re always looking for people who value their work, so come and join us. We are hiring! - We’re always looking for people who value their work, so come and join us. We are hiring! ## Architecture @@ -42,51 +42,58 @@ The driving goal of the architecture of the template is separation of concerns. - **Presentational components are separated from scenes** (aka "screens"). - Presentational components are small components that are concerned with *how things look*. Scenes usually define whole application screens and are concerned with *how things work*: they include presentational components and wire everything together. - - If you are interested you can [read more about it here](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0). - + Presentational components are small components that are concerned with _how things look_. Scenes usually define whole application screens and are concerned with _how things work_: they include presentational components and wire everything together. + + If you are interested you can [read more about it here](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0). + ### Atomic Design for react native architecture -Atomic design further solidifies the idea of seperating screens into components and scenes (containers). The design primarily focuses on reusablity of code, which brings us to the differentiation of components into atoms, molecules and organisms. Analogous to the Atomic design of chemicals, components are seperated by their composition. The components require increasing context as their complexity increases, since each component is tested, this promotes a more granular test coverage. - - - **Atoms** - Atoms are the smallest components that can be reused. Button, Text, and Icons are good example of Atoms. Atoms can be used without context and cannot be further divided. -- **Molecules** - Molecules are built from one or more atoms that are slightly complex presentational components. +Atomic design further solidifies the idea of separating screens into components and scenes (containers). The design primarily focuses on reusability of code, which brings us to the differentiation of components into atoms, molecules, and organisms. Analogous to the Atomic design of chemicals, components are separated by their composition. The components require increasing context as their complexity increases, since each component is tested, this promotes a more granular test coverage. + +- **Atoms** + Atoms are the smallest components that can be reused. Button, Text, and Icons are good examples of Atoms. Atoms can be used without context and cannot be further divided. + +- **Molecules** + Molecules are built from one or more atoms that are slightly complex presentational components. + +- **Organisms** + Organisms contain multiple molecules, atoms, and perform a specific purpose. In the example screen, an organism is used that displays the fetched character and quote. + +- **State is managed using [Recoil](https://recoiljs.org/)**. + + Recoil provides a set of utilities to manage global state in React Native. It allows for atoms (the smallest units of state) and selectors (to transform or combine state) to handle the app's state efficiently. This eliminates the need for Redux, actions, and reducers, simplifying the process for managing state across components. -- **Organisms** - Organisms contain multiple molecules, atoms and perform a specific purpose. In the example screen, an organism is used that displays the fetched character and quote. + Atoms are the core units of state, and selectors are derived state values computed from one or more atoms. Recoil's state management is highly reactive and more efficient for handling state at a granular level. -- **State is managed using global [Redux](https://redux.js.org/) stores**. + If you are interested you can [read more about it here](https://recoiljs.org/docs/introduction/getting-started). - When applications grow, sharing state and its changes can become very hard. Questions like "How can I access this data?" or "When did this change?" are common, just like passing data around components just to be able to use it in nested components. - - With Redux, state is shared using global *stores*, and changes are predictable: *actions* are applied by *reducers* to the state. While the pattern can be a bit much for small projects, the clear separation of responsibilities and predictability helps with bigger applications. - - If you are interested you can [read more about it here](https://redux.js.org/introduction/motivation). - -- **Application side-effects (API calls, etc.) are separated from UI and state manipulation using [Redux Saga](https://redux-saga.js.org/)**. +- **Side Effects (API calls, etc.) are managed within components or with Recoil selectors**. - Using Redux Saga has two benefits: keeping application side-effects and related business logic out of UI components, as well as executing that logic in an asynchronous way without ending in callback hell. - - Sagas are triggered by Redux actions and can also trigger Redux actions to alter state. By using JavaScript generators (`yield`), sagas are written in a synchronous-like manner while still executing asynchronously. + Recoil allows for managing side effects within the components themselves or through asynchronous selectors. This keeps your side effects closer to where they are needed. + +## Analytics, Feature Flagging, and Error Tracking + +- **[PostHog](https://posthog.com/)** is integrated to provide analytics and event tracking across the application. PostHog captures user interactions and events, which helps in analyzing user behavior and improving the app based on data-driven insights. + +- **[GrowthBook](https://www.growthbook.io/)** is used for feature flagging. With GrowthBook, you can easily manage the rollout of features to users, enabling A/B testing, and controlling which features are visible to which segments of your user base without redeploying the app. + +- **[Sentry](https://sentry.io/)** is used for error tracking and reporting. Sentry captures errors and exceptions from the app in real time, providing detailed insights into the errors that occur, enabling faster bug fixing and better app stability. ## Content The React Native Template contains: -- a [React Native](https://facebook.github.io/react-native/) (v**0.60.6**) application (in "[ejected](https://github.com/react-community/create-react-native-app/blob/master/EJECTING.md)" mode to allow using dependencies that rely on native code) +- a [React Native](https://facebook.github.io/react-native/) (v**0.73.6**) application (in "[ejected](https://github.com/react-community/create-react-native-app/blob/master/EJECTING.md)" mode to allow using dependencies that rely on native code) - a [clear directory layout](#directory-layout) to provide a base architecture for your application -- [Redux](https://redux.js.org/) (v4.0.1) to help manage state -- [Redux Persist](https://github.com/rt2zz/redux-persist) (v5.10.0) to persist the Redux state -- [Redux Sagas](https://redux-saga.js.org) (v1.0.2) to separate side-effects and logic from state and UI logic -- [React Navigation](https://reactnavigation.org/) (v3.11.2) with a [`NavigationService`](app/services/navigationService.js) to handle routing and navigation in the app, with a splash screen setup by default -- [reduxsauce](https://github.com/infinitered/reduxsauce) (v1.0.1) to facilitate using Redux -- [apisauce](https://github.com/infinitered/apisauce/) to make API calls (v0.19.0) +- [Recoil](https://recoiljs.org/) to manage global state +- [React Navigation](https://reactnavigation.org/) (v5.3.15) with a [`NavigationService`](app/services/navigationService.js) to handle routing and navigation in the app, with a splash screen setup by default +- [axios](https://github.com/axios/axios/) to make API calls (v0.27.2) +- [PostHog](https://posthog.com/) for analytics +- [GrowthBook](https://www.growthbook.io/) for feature flagging +- [Sentry](https://sentry.io/) for error tracking - [prettier](https://prettier.io/) and [eslint](https://eslint.org/) preconfigured for React Native -The template includes an example (displaying fake user data) from UI components to the saga. The example is easy to remove so that it doesn't get in the way. +The template includes an example (displaying fake user data) from UI components to state management using Recoil. The example is easy to remove so that it doesn't get in the way. ## Directory layout @@ -97,12 +104,10 @@ The template includes an example (displaying fake user data) from UI components - [`app/scenes`](app/components/scenes): scenes are screens that can be navigated to - [`app/config`](app/config): configuration of the application - [`app/assets`](app/assets): assets (image, audio files, ...) used by the application -- [`app/navigators`](app/navigators): react navigation navigators +- [`app/navigators`](app/navigators): react navigation navigators - [`app/services`](app/services): application services, e.g. API clients - [`app/utils`](app/utils): Util methods and constants - [`app/themes`](app/themes): base styles for the application -- [`app/rootSaga`](app/rootSaga): all the individual sagas need to be added here. -- [`app/rootReducer`](app/rootReducer): all the individual reducers need to be added here. For more information on each directory, click the link and read the directory's README. @@ -112,9 +117,8 @@ Node 8 or greater is required. Development for iOS requires a Mac and Xcode 9 or You also need to install the dependencies required by React Native: -- for [Android development](https://facebook.github.io/react-native/docs/getting-started.html#installing-dependencies-3) -- for [iOS development](https://facebook.github.io/react-native/docs/getting-started.html#installing-dependencies) - +- for [Android development](https://reactnative.dev/docs/set-up-your-environment?platform=android) +- for [iOS development](https://reactnative.dev/docs/set-up-your-environment?platform=ios) ## Using the template @@ -126,16 +130,16 @@ To create a new project using the template: - rename the React Native project to your own project name: `yarn run rename -- ` (the default name is `ReactNativeApplication`) - remove the LICENSE file and the "License" section from the README if your project is not open source - ### Running expo project ### Android - - `yarn run android` + +- `yarn run android` ### iOS + - `yarn run ios` - ## Useful documentation ### Deployment @@ -145,5 +149,5 @@ To create a new project using the template: ### Package dependencies -- You may want to use [CocoaPods](https://cocoapods.org/) to manage your dependencies (iOS only) +- You may want to use [CocoaPods](https://cocoapods.org/) to manage your dependencies (iOS only) - [Using CocoaPods to manage your package dependencies](docs/setup%20cocoapods.md) diff --git a/android/app/build.gradle b/android/app/build.gradle index aeaef48..0ac5d44 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -77,6 +77,7 @@ def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInRelea */ def jscFlavor = 'org.webkit:android-jsc:+' +apply from: new File(["node", "--print", "require.resolve('@sentry/react-native/package.json')"].execute().text.trim(), "../sentry.gradle") android { ndkVersion rootProject.ext.ndkVersion @@ -173,4 +174,4 @@ dependencies { } apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), "../native_modules.gradle"); -applyNativeModulesAppBuildGradle(project) +applyNativeModulesAppBuildGradle(project) \ No newline at end of file diff --git a/android/sentry.properties b/android/sentry.properties new file mode 100644 index 0000000..107ad3f --- /dev/null +++ b/android/sentry.properties @@ -0,0 +1,7 @@ + +auth.token=YOUR_SENTRY_AUTH_TOKEN + +defaults.org=YOUR_ORG_NAME +defaults.project=YOUR_PROJECT_NAME + +defaults.url=https://sentry.io/ diff --git a/app.json b/app.json index cff2191..48b322b 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "expo": { "name": "react-native-template-ws", - "slug": "rnt-ws", + "slug": "react-native-template-cd", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", @@ -22,6 +22,16 @@ }, "ios": { "bundleIdentifier": "com.wednesdaysolutions.rntws" - } + }, + "plugins": [ + [ + "@sentry/react-native/expo", + { + "url": "https://sentry.io/", + "project": "react-native", + "organization": "wednesday-solutions-5p" + } + ] + ] } } diff --git a/app/app.js b/app/app.js index c45d42b..f099239 100644 --- a/app/app.js +++ b/app/app.js @@ -1,22 +1,19 @@ import React from 'react'; -import { Provider } from 'react-redux'; -import { PersistGate } from 'redux-persist/lib/integration/react'; +import { RecoilRoot } from 'recoil'; +import { I18nextProvider } from 'react-i18next'; +import 'react-native-gesture-handler'; import LanguageProvider from '@atoms/LanguageProvider'; import RootScreen from '@scenes/RootScreen'; -import createStore from '@app/rootReducer'; -import { translationMessages } from './i18n'; -import 'react-native-gesture-handler'; - -const { store, persistor } = createStore(); +import i18n from '@app/i18n'; const App = () => ( - - - + + + - - - + + + ); export default App; diff --git a/app/assets/images/wednesday-logo-new.png b/app/assets/images/wednesday-logo-new.png new file mode 100644 index 0000000..a7a9a9d Binary files /dev/null and b/app/assets/images/wednesday-logo-new.png differ diff --git a/app/assets/images/wednesday-logo-old.png b/app/assets/images/wednesday-logo-old.png new file mode 100755 index 0000000..db6d8a5 Binary files /dev/null and b/app/assets/images/wednesday-logo-old.png differ diff --git a/app/assets/images/wednesday-logo.png b/app/assets/images/wednesday-logo.png old mode 100755 new mode 100644 index db6d8a5..c7f3a03 Binary files a/app/assets/images/wednesday-logo.png and b/app/assets/images/wednesday-logo.png differ diff --git a/app/assets/images/wednesday-logo.svg b/app/assets/images/wednesday-logo.svg new file mode 100644 index 0000000..00ba80b --- /dev/null +++ b/app/assets/images/wednesday-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/components/atoms/Container/tests/__snapshots__/index.test.js.snap b/app/components/atoms/Container/tests/__snapshots__/index.test.js.snap index 1a1f520..0cf3656 100644 --- a/app/components/atoms/Container/tests/__snapshots__/index.test.js.snap +++ b/app/components/atoms/Container/tests/__snapshots__/index.test.js.snap @@ -3,12 +3,14 @@ exports[` should render and match the snapshot 1`] = ` diff --git a/app/components/atoms/Container/tests/index.test.js b/app/components/atoms/Container/tests/index.test.js index 6ea3098..955713a 100644 --- a/app/components/atoms/Container/tests/index.test.js +++ b/app/components/atoms/Container/tests/index.test.js @@ -4,17 +4,17 @@ * */ import React from 'react'; -import { renderWithIntl } from 'app/utils/testUtils'; +import { renderWithI18next } from 'app/utils/testUtils'; import Container from '../index'; describe('', () => { it('should render and match the snapshot', () => { - const baseElement = renderWithIntl(); + const baseElement = renderWithI18next(); expect(baseElement).toMatchSnapshot(); }); it('should contain 1 container', () => { - const { getAllByTestId } = renderWithIntl(); + const { getAllByTestId } = renderWithI18next(); expect(getAllByTestId('container').length).toBe(1); }); }); diff --git a/app/components/atoms/LanguageProvider/actions.js b/app/components/atoms/LanguageProvider/actions.js deleted file mode 100644 index 8dfbef5..0000000 --- a/app/components/atoms/LanguageProvider/actions.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * - * LanguageProvider actions - * - */ - -import { CHANGE_LOCALE } from './constants'; -/** - * Changes the locale/language of the application. - * @param {string} languageLocale - The new locale/language to set. - * @returns {object} An action object with type 'CHANGE_LOCALE' and the new locale/language. - */ -export function changeLocale(languageLocale) { - return { - type: CHANGE_LOCALE, - locale: languageLocale - }; -} diff --git a/app/components/atoms/LanguageProvider/constants.js b/app/components/atoms/LanguageProvider/constants.js deleted file mode 100644 index f4d838d..0000000 --- a/app/components/atoms/LanguageProvider/constants.js +++ /dev/null @@ -1 +0,0 @@ -export const CHANGE_LOCALE = 'changeLocale'; diff --git a/app/components/atoms/LanguageProvider/index.js b/app/components/atoms/LanguageProvider/index.js index ecde1bb..26825fb 100644 --- a/app/components/atoms/LanguageProvider/index.js +++ b/app/components/atoms/LanguageProvider/index.js @@ -1,56 +1,23 @@ -/* - * - * LanguageProvider - * - * this component connects the redux state language locale to the - * IntlProvider component and i18n messages (loaded from `app/translations`) - */ - import React from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { IntlProvider } from 'react-intl'; +import { useTranslation } from 'react-i18next'; -import { makeSelectLocale } from './selectors'; /** - * Provides internationalization (i18n) support by wrapping components with an IntlProvider. + * Provides internationalization (i18n) support by ensuring that the necessary + * translations and locale information are available throughout the app. + * * @param {object} props - The props object containing component properties. - * @param {string} props.locale - The locale/language code for internationalization. - * @param {object} props.messages - An object containing locale-specific message translations. - * @param {React.ReactNode} props.children - The child elements/components to be wrapped and rendered. - * @returns {React.ReactNode} A JSX element wrapping the provided child components with IntlProvider. + * @param {React.ReactNode} props.children - The child elements/components to be rendered. + * @returns {React.ReactNode} A JSX element wrapping the provided child components. */ -export function LanguageProvider(props) { - return ( - - {React.Children.only(props.children)} - - ); +export function LanguageProvider({ children }) { + useTranslation(); // This initializes the i18next context for this component tree + + return <>{React.Children.only(children)}; } LanguageProvider.propTypes = { - locale: PropTypes.string, - messages: PropTypes.object, children: PropTypes.element.isRequired }; -const mapStateToProps = createSelector(makeSelectLocale(), locale => ({ - locale -})); -/** - * Generates and returns an object containing action dispatch functions. - * @param {function} dispatch - The Redux store's dispatch function. - * @returns {object} An object containing action dispatch functions wrapped for use in components. - */ -function mapDispatchToProps(dispatch) { - return { - dispatch - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(LanguageProvider); +export default LanguageProvider; diff --git a/app/components/atoms/LanguageProvider/reducer.js b/app/components/atoms/LanguageProvider/reducer.js deleted file mode 100644 index b67a969..0000000 --- a/app/components/atoms/LanguageProvider/reducer.js +++ /dev/null @@ -1,34 +0,0 @@ -/* eslint-disable sonarjs/no-small-switch */ -/* - * - * LanguageProvider reducer - * - */ -import { createActions } from 'reduxsauce'; -import { fromJS } from 'immutable'; -import produce from 'immer'; -import { DEFAULT_LOCALE } from '@app/i18n'; - -export const { - Types: languageProviderTypes, - Creators: languageProviderActions -} = createActions({ - changeLocale: ['locale'] -}); - -export const initialState = fromJS({ - locale: DEFAULT_LOCALE -}); - -/* eslint-disable default-case, no-param-reassign */ -export const languageProviderReducer = (state = initialState, action) => - produce(state, (/* draft */) => { - switch (action.type) { - case languageProviderTypes.CHANGE_LOCALE: - return state.set('locale', action.locale); - default: - return state; - } - }); - -export default languageProviderReducer; diff --git a/app/components/atoms/LanguageProvider/selectors.js b/app/components/atoms/LanguageProvider/selectors.js deleted file mode 100644 index c5d4462..0000000 --- a/app/components/atoms/LanguageProvider/selectors.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createSelector } from 'reselect'; -import { initialState } from './reducer'; - -/** - * Direct selector to the languageToggle state domain - */ -const selectLanguage = state => (state.language || initialState).toJS(); - -/** - * Select the language locale - */ - -const makeSelectLocale = () => - createSelector(selectLanguage, languageState => languageState.locale); - -export { selectLanguage, makeSelectLocale }; diff --git a/app/components/atoms/LanguageProvider/tests/action.test.js b/app/components/atoms/LanguageProvider/tests/action.test.js deleted file mode 100644 index 29e5b1c..0000000 --- a/app/components/atoms/LanguageProvider/tests/action.test.js +++ /dev/null @@ -1,14 +0,0 @@ -import { changeLocale } from '../actions'; -import { CHANGE_LOCALE } from '../constants'; - -describe('changeLocale action creator', () => { - it('should create an action to change the locale', () => { - const languageLocale = 'en'; - const expectedAction = { - type: CHANGE_LOCALE, - locale: languageLocale - }; - const action = changeLocale(languageLocale); - expect(action).toEqual(expectedAction); - }); -}); diff --git a/app/components/atoms/LanguageProvider/tests/index.test.js b/app/components/atoms/LanguageProvider/tests/index.test.js index fc1f33a..f103dcc 100644 --- a/app/components/atoms/LanguageProvider/tests/index.test.js +++ b/app/components/atoms/LanguageProvider/tests/index.test.js @@ -1,10 +1,7 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -import { Provider } from 'react-redux'; import T from '@atoms/T'; -import createStore from 'app/rootReducer'; -import { translationMessages } from 'app/i18n'; -import { renderWithIntl } from '@utils/testUtils'; +import { renderWithI18next } from '@utils/testUtils'; import { Text } from 'react-native'; import ConnectedLanguageProvider, { LanguageProvider } from '../index'; describe(' container tests', () => { @@ -14,24 +11,19 @@ describe(' container tests', () => { Test

); - const container = renderWithIntl( - - {children} - + const container = renderWithI18next( + {children} ); expect(container.firstChild).not.toBeNull(); }); }); -const setupReduxStore = () => ({ reduxStore: createStore().store }); + describe(' container tests', () => { it('should render the default language messages', () => { - const { reduxStore } = setupReduxStore(); const { queryByText } = render( - - - - - + + + ); expect(queryByText('because')).not.toBeNull(); }); diff --git a/app/components/atoms/LanguageProvider/tests/reducer.test.js b/app/components/atoms/LanguageProvider/tests/reducer.test.js deleted file mode 100644 index f1288dc..0000000 --- a/app/components/atoms/LanguageProvider/tests/reducer.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { - initialState, - languageProviderTypes, - languageProviderReducer -} from '../reducer'; -const setupMockedState = state => ({ mockedState: state }); -/* eslint-disable default-case, no-param-reassign */ -describe('Tests for LanguageProvider actions', () => { - it('returns the initial state', () => { - const { mockedState } = setupMockedState(initialState); - expect(languageProviderReducer(undefined, {})).toEqual(mockedState); - }); - - it('changes the locale', () => { - const locale = 'de'; - const { mockedState } = setupMockedState( - initialState.set('locale', locale) - ); - expect( - languageProviderReducer(undefined, { - type: languageProviderTypes.CHANGE_LOCALE, - locale - }) - ).toEqual(mockedState); - }); -}); diff --git a/app/components/atoms/LanguageProvider/tests/selectors.test.js b/app/components/atoms/LanguageProvider/tests/selectors.test.js deleted file mode 100644 index e4fb65d..0000000 --- a/app/components/atoms/LanguageProvider/tests/selectors.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { fromJS } from 'immutable'; -import { selectLanguage } from '../selectors'; - -describe('Tests for LanguageProvider selectors', () => { - it('should select the global state', () => { - const globalState = {}; - const mockedState = { - language: fromJS(globalState) - }; - expect(selectLanguage(mockedState)).toEqual(globalState); - }); -}); diff --git a/app/components/atoms/T/index.js b/app/components/atoms/T/index.js index 92c9e00..4b05071 100644 --- a/app/components/atoms/T/index.js +++ b/app/components/atoms/T/index.js @@ -6,15 +6,19 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { compose } from 'redux'; -import { injectIntl } from 'react-intl'; import { Text } from 'react-native'; -const T = ({ intl, id, values, style, text, ...otherProps }) => ( - - {id ? intl.formatMessage({ id }, { ...values }) : text} - -); +import { useTranslation } from 'react-i18next'; +import { conditionalOperatorFunction } from '@app/utils/common'; + +const T = ({ intl, id, values, style, text, ...otherProps }) => { + const { t } = useTranslation(); + return ( + + {conditionalOperatorFunction(id, t(id, { ...values }), text)} + + ); +}; T.propTypes = { id: PropTypes.string, @@ -27,4 +31,4 @@ T.defaultProps = { text: '' }; -export default compose(injectIntl)(T); +export default T; diff --git a/app/components/atoms/T/tests/index.test.js b/app/components/atoms/T/tests/index.test.js index dfdb8be..7a26d9e 100644 --- a/app/components/atoms/T/tests/index.test.js +++ b/app/components/atoms/T/tests/index.test.js @@ -4,17 +4,17 @@ * */ import React from 'react'; -import { renderWithIntl } from 'app/utils/testUtils'; +import { renderWithI18next } from 'app/utils/testUtils'; import T from '../index'; describe('', () => { it('should render and match the snapshot', () => { - const baseElement = renderWithIntl(); + const baseElement = renderWithI18next(); expect(baseElement).toMatchSnapshot(); }); it('should contain 1 t', () => { - const { getAllByTestId } = renderWithIntl(); + const { getAllByTestId } = renderWithI18next(); expect(getAllByTestId('t').length).toBe(1); }); }); diff --git a/app/components/molecules/CharacterWithQuote/tests/__snapshots__/index.test.js.snap b/app/components/molecules/CharacterWithQuote/tests/__snapshots__/index.test.js.snap index 1f75c07..e32d2e2 100644 --- a/app/components/molecules/CharacterWithQuote/tests/__snapshots__/index.test.js.snap +++ b/app/components/molecules/CharacterWithQuote/tests/__snapshots__/index.test.js.snap @@ -4,27 +4,29 @@ exports[` Should render and match the snapshot 1`] = ` [ - character loves Wednesday + wednesday_lover , because , @@ -36,27 +38,30 @@ exports[` Should render and match the snapshot 1`] = ` } } style={ - { - "height": 80, - "marginBottom": 0, - "marginLeft": "auto", - "marginRight": "auto", - "marginTop": 0, - "width": 80, - } + [ + { + "height": 80, + "marginBottom": 0, + "marginLeft": "auto", + "marginRight": "auto", + "marginTop": 0, + "width": 80, + }, + ] } testID="character-image" />, , ] `; diff --git a/app/components/molecules/CharacterWithQuote/tests/index.test.js b/app/components/molecules/CharacterWithQuote/tests/index.test.js index c967036..c811767 100644 --- a/app/components/molecules/CharacterWithQuote/tests/index.test.js +++ b/app/components/molecules/CharacterWithQuote/tests/index.test.js @@ -8,12 +8,12 @@ import React from 'react'; import get from 'lodash/get'; -import { renderWithIntl } from '@utils/testUtils'; +import { renderWithI18next } from '@utils/testUtils'; import CharacterWithQuote from '../index'; describe('', () => { it('Should render and match the snapshot', () => { - const baseElement = renderWithIntl(); + const baseElement = renderWithI18next(); expect(baseElement).toMatchSnapshot(); }); @@ -26,10 +26,10 @@ describe('', () => { quote: "D'Oh!" } }; - const { getByText, getByTestId } = renderWithIntl( + const { getByText, getByTestId } = renderWithI18next( ); - expect(getByText('Homer loves Wednesday')).toBeTruthy(); + expect(getByText('wednesday_lover')).toBeTruthy(); expect(getByText(props.user.quote)).toBeTruthy(); const characterImageURI = get( getByTestId('character-image'), diff --git a/app/components/molecules/LogoWithInstructions/tests/__snapshots__/index.test.js.snap b/app/components/molecules/LogoWithInstructions/tests/__snapshots__/index.test.js.snap index 5f9e96c..6dba70c 100644 --- a/app/components/molecules/LogoWithInstructions/tests/__snapshots__/index.test.js.snap +++ b/app/components/molecules/LogoWithInstructions/tests/__snapshots__/index.test.js.snap @@ -15,7 +15,7 @@ exports[` Should render and match the snapshot 1`] = ` resizeMode="contain" source={ { - "testUri": "../../../app/assets/images/wednesday-logo.png", + "testUri": "../../../app/assets/images/wednesday-logo-new.png", } } style={ @@ -29,15 +29,16 @@ exports[` Should render and match the snapshot 1`] = ` `; diff --git a/app/components/molecules/LogoWithInstructions/tests/index.test.js b/app/components/molecules/LogoWithInstructions/tests/index.test.js index 6c7c085..9837f53 100644 --- a/app/components/molecules/LogoWithInstructions/tests/index.test.js +++ b/app/components/molecules/LogoWithInstructions/tests/index.test.js @@ -7,17 +7,17 @@ */ import React from 'react'; -import { renderWithIntl } from '@utils/testUtils'; +import { renderWithI18next } from '@utils/testUtils'; import LogoWithInstructions from '../index'; describe('', () => { it('Should render and match the snapshot', () => { - const baseElement = renderWithIntl(); + const baseElement = renderWithI18next(); expect(baseElement).toMatchSnapshot(); }); it('should render the instructions pased as props', () => { const instructions = 'PRESS CMD + D for iOS'; - const { getByText } = renderWithIntl( + const { getByText } = renderWithI18next( ); expect(getByText(instructions)).toBeTruthy(); diff --git a/app/components/organisms/SimpsonsLoveWednesday/tests/__snapshots__/index.test.js.snap b/app/components/organisms/SimpsonsLoveWednesday/tests/__snapshots__/index.test.js.snap index 95f0b05..8870df6 100644 --- a/app/components/organisms/SimpsonsLoveWednesday/tests/__snapshots__/index.test.js.snap +++ b/app/components/organisms/SimpsonsLoveWednesday/tests/__snapshots__/index.test.js.snap @@ -16,7 +16,7 @@ exports[` Should render and match the snapshot 1`] = ` resizeMode="contain" source={ { - "testUri": "../../../app/assets/images/wednesday-logo.png", + "testUri": "../../../app/assets/images/wednesday-logo-new.png", } } style={ @@ -30,43 +30,50 @@ exports[` Should render and match the snapshot 1`] = ` , - character loves Wednesday + wednesday_lover because @@ -78,27 +85,30 @@ exports[` Should render and match the snapshot 1`] = ` } } style={ - { - "height": 80, - "marginBottom": 0, - "marginLeft": "auto", - "marginRight": "auto", - "marginTop": 0, - "width": 80, - } + [ + { + "height": 80, + "marginBottom": 0, + "marginLeft": "auto", + "marginRight": "auto", + "marginTop": 0, + "width": 80, + }, + ] } testID="character-image" /> , ] diff --git a/app/components/organisms/SimpsonsLoveWednesday/tests/index.test.js b/app/components/organisms/SimpsonsLoveWednesday/tests/index.test.js index 0be176e..13ed140 100644 --- a/app/components/organisms/SimpsonsLoveWednesday/tests/index.test.js +++ b/app/components/organisms/SimpsonsLoveWednesday/tests/index.test.js @@ -7,12 +7,12 @@ */ import React from 'react'; -import { renderWithIntl } from '@utils/testUtils'; +import { renderWithI18next } from '@utils/testUtils'; import { rerender } from '@testing-library/react-native'; import SimpsonsLoveWednesday from '../index'; describe('', () => { it('Should render and match the snapshot', () => { - const baseElement = renderWithIntl(); + const baseElement = renderWithI18next(); expect(baseElement).toMatchSnapshot(); }); it('Should render the Error component if userErrorMessage is not empty', () => { @@ -26,7 +26,9 @@ describe('', () => { quote: "D'Oh!" } }; - const { getByText } = renderWithIntl(); + const { getByText } = renderWithI18next( + + ); expect(getByText(props.userErrorMessage)).toBeTruthy(); }); it('Should render the component if userErrorMessage is empty', () => { @@ -40,12 +42,10 @@ describe('', () => { quote: "D'Oh!" } }; - const { getByText: textQueryOnReRender } = renderWithIntl( + const { getByText: textQueryOnReRender } = renderWithI18next( , rerender ); - expect( - textQueryOnReRender(`${props.user.character} loves Wednesday`) - ).toBeTruthy(); + expect(textQueryOnReRender(`wednesday_lover`)).toBeTruthy(); }); }); diff --git a/app/config/index.dev.js b/app/config/index.dev.js index 5482277..d03639d 100644 --- a/app/config/index.dev.js +++ b/app/config/index.dev.js @@ -1,3 +1,5 @@ +import { JSON_PLACEHOLDER_API } from '@env'; + export const Config = { - API_URL: 'https://jsonplaceholder.typicode.com/users/' + API_URL: `${JSON_PLACEHOLDER_API}/users/` }; diff --git a/app/config/index.js b/app/config/index.js index 6beae24..01e9e29 100644 --- a/app/config/index.js +++ b/app/config/index.js @@ -1,3 +1,5 @@ +import { SIMPSONS_API } from '@env'; + export const Config = { - API_URL: 'https://thesimpsonsquoteapi.glitch.me/' + API_URL: SIMPSONS_API }; diff --git a/app/config/index.production.js b/app/config/index.production.js index 5482277..d03639d 100644 --- a/app/config/index.production.js +++ b/app/config/index.production.js @@ -1,3 +1,5 @@ +import { JSON_PLACEHOLDER_API } from '@env'; + export const Config = { - API_URL: 'https://jsonplaceholder.typicode.com/users/' + API_URL: `${JSON_PLACEHOLDER_API}/users/` }; diff --git a/app/i18n.js b/app/i18n.js index 8bb9127..5338e08 100644 --- a/app/i18n.js +++ b/app/i18n.js @@ -1,3 +1,5 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; /* eslint-disable fp/no-mutating-assign */ /** * i18n.js @@ -8,36 +10,31 @@ * script `extract-intl`, and must use CommonJS module syntax * You CANNOT use import/export in this file. */ -const addLocaleData = require('react-intl').addLocaleData; //eslint-disable-line - -const enLocaleData = require('react-intl/locale-data/en'); const enTranslationMessages = require('./translations/en.json'); -addLocaleData(enLocaleData); - -export const DEFAULT_LOCALE = 'en'; - -// prettier-ignore -export const appLocales = [ - 'en', -]; - -export const formatTranslationMessages = (locale, messages) => { - const defaultFormattedMessages = - locale !== DEFAULT_LOCALE - ? formatTranslationMessages(DEFAULT_LOCALE, enTranslationMessages) - : {}; - const flattenFormattedMessages = (formattedMessages, key) => { - const formattedMessage = - !messages[key] && locale !== DEFAULT_LOCALE - ? defaultFormattedMessages[key] - : messages[key]; - return Object.assign(formattedMessages, { [key]: formattedMessage }); - }; - return Object.keys(messages).reduce(flattenFormattedMessages, {}); +export const languageDetector = { + type: 'languageDetector', + async: true, + detect: cb => cb('en'), + init: () => {}, + cacheUserLanguage: () => {} }; -export const translationMessages = { - en: formatTranslationMessages('en', enTranslationMessages) -}; +i18n + .use(languageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + debug: false, + resources: { + en: { + translation: enTranslationMessages + } + }, + interpolation: { + escapeValue: true + } + }); + +export default i18n; diff --git a/app/i18n.test.js b/app/i18n.test.js index 0257380..9d082be 100644 --- a/app/i18n.test.js +++ b/app/i18n.test.js @@ -1,28 +1,46 @@ -import { formatTranslationMessages } from './i18n'; +import i18next from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { languageDetector } from './i18n'; -jest.mock('app/translations/en.json', () => ({ - message1: 'default message', - message2: 'default message 2' -})); +describe('i18n configuration', () => { + it('should configure i18next with the correct settings', () => { + // Import the i18n configuration -const esTranslationMessages = { - message1: 'mensaje predeterminado', - message2: '' -}; + // Verify that the language detector was used + expect(i18next.use).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'languageDetector', + async: true, + detect: expect.any(Function), + init: expect.any(Function), + cacheUserLanguage: expect.any(Function) + }) + ); -describe('Tests for formatTranslationMessages', () => { - it('should build only defaults when DEFAULT_LOCALE', () => { - const result = formatTranslationMessages('en', { a: 'a' }); + // Verify that the initReactI18next was used + expect(i18next.use).toHaveBeenCalledWith(initReactI18next); - expect(result).toEqual({ a: 'a' }); + // Verify that i18next was initialized with the correct configuration + expect(i18next.init).toHaveBeenCalledWith( + expect.objectContaining({ + fallbackLng: 'en', + debug: false, + resources: { + en: { + translation: expect.any(Object) // This should match the contents of enTranslationMessages + } + }, + interpolation: { + escapeValue: true + } + }) + ); }); - it('should combine default locale and current locale when not DEFAULT_LOCALE', () => { - const result = formatTranslationMessages('', esTranslationMessages); - - expect(result).toEqual({ - message1: 'mensaje predeterminado', - message2: 'default message 2' + it('should detect language as "en" using the language detector', () => { + // Call the detect function and ensure it was passed 'en' + languageDetector.detect(language => { + expect(language).toBe('en'); }); }); }); diff --git a/app/navigators/appNavigator.js b/app/navigators/appNavigator.js index 3ad9469..c4de5af 100644 --- a/app/navigators/appNavigator.js +++ b/app/navigators/appNavigator.js @@ -1,9 +1,11 @@ import React from 'react'; +import { PostHogProvider } from 'posthog-react-native'; import { createStackNavigator } from '@react-navigation/stack'; import SplashScreen from '@scenes/SplashScreen/'; import ExampleScreen from '@scenes/ExampleScreen'; import { NavigationContainer } from '@react-navigation/native'; import { setTopLevelNavigator } from '@services/navigationService'; +import { getPostHogClient } from '@app/utils/posthogUtils'; const Stack = createStackNavigator(); /** * The root screen contains the application's navigation. @@ -13,10 +15,15 @@ const Stack = createStackNavigator(); export default function AppNavigator() { return ( - - - - + + + + + + ); } diff --git a/app/rootReducer.js b/app/rootReducer.js deleted file mode 100644 index 21cf2ff..0000000 --- a/app/rootReducer.js +++ /dev/null @@ -1,12 +0,0 @@ -import { combineReducers } from 'redux'; -import { exampleContainerReducer as example } from '@scenes/ExampleScreen/reducer'; -import configureStore from '@app/utils/createStore'; -import rootSaga from '@app/rootSaga'; - -export default () => { - const rootReducer = combineReducers({ - example - }); - - return configureStore(rootReducer, rootSaga); -}; diff --git a/app/rootSaga.js b/app/rootSaga.js deleted file mode 100644 index a025ca9..0000000 --- a/app/rootSaga.js +++ /dev/null @@ -1,12 +0,0 @@ -import { fork } from 'redux-saga/effects'; -import exampleSaga from '@scenes/ExampleScreen/saga'; -import startupSaga from '@scenes/RootScreen/saga'; -/** - * Root saga generator function that orchestrates other sagas. - * This function sets up and manages the execution of multiple sagas using fork effects. - * @returns {IterableIterator} An iterator for managing the execution of sagas. - */ -export default function* root() { - yield fork(exampleSaga); - yield fork(startupSaga); -} diff --git a/app/scenes/ExampleScreen/index.js b/app/scenes/ExampleScreen/index.js index 3b273c1..eb27126 100644 --- a/app/scenes/ExampleScreen/index.js +++ b/app/scenes/ExampleScreen/index.js @@ -1,28 +1,22 @@ +import React, { useEffect } from 'react'; import { Button, Platform, View, ActivityIndicator } from 'react-native'; -import { connect } from 'react-redux'; -import { compose } from 'redux'; -import { PropTypes } from 'prop-types'; +import { + useRecoilState, + useSetRecoilState, + useRecoilValueLoadable +} from 'recoil'; import styled from 'styled-components/native'; -import { createStructuredSelector } from 'reselect'; -import { injectIntl } from 'react-intl'; -import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { usePostHog } from 'posthog-react-native'; import AppContainer from '@atoms/Container'; import SimpsonsLoveWednesday from '@organisms/SimpsonsLoveWednesday'; +import If from '@app/components/atoms/If'; +import { conditionalOperatorFunction } from '@app/utils/common'; +import { LoadingStates } from '@app/utils/constants'; +import { POSTHOG_EVENTS } from '@app/utils/posthogEvents'; -import { - selectUser, - selectUserIsLoading, - selectUserErrorMessage -} from './selectors'; -import { exampleScreenActions } from './reducer'; - -/** - * This is an example of a container component. - * - * This screen displays a little help message and informations about a fake user. - * Feel free to remove it. - */ +import { userState, fetchUserSelector, fetchTriggerState } from './recoilState'; const Container = styled(AppContainer)` margin: 30px; @@ -38,56 +32,65 @@ const CustomButtonParentView = styled(View)` max-width: 80px; align-self: center; `; -const instructions = Platform.select({ - ios: 'Press Cmd+R to reload,\nCmd+D or shake for dev menu.', - android: - 'Double tap R on your keyboard to reload,\nShake or press menu button for dev menu.' -}); -const ExampleScreen = props => { +const ExampleScreen = () => { + const [user, setUser] = useRecoilState(userState); + const setFetchTrigger = useSetRecoilState(fetchTriggerState); + const userLoadable = useRecoilValueLoadable(fetchUserSelector); + const posthog = usePostHog(); + const { t } = useTranslation(); const requestFetchUser = () => { - props.fetchUser(); + setFetchTrigger(prev => prev + 1); }; + const instructions = Platform.select({ + ios: t('ios_instructions'), + android: t('android_instructions') + }); + useEffect(() => { requestFetchUser(); }, []); + + useEffect(() => { + if (userLoadable.state === LoadingStates.HAS_VALUE) { + setUser(userLoadable.contents); + } + }, [userLoadable?.contents?.character]); + + const refreshButtonHandler = () => { + posthog.capture(POSTHOG_EVENTS.REFRESH_BUTTON_CLICKED); + requestFetchUser(); + }; + return ( - {props.userIsLoading ? ( + + + + + + + } + > - ) : ( - - - -