diff --git a/.detoxrc.js b/.detoxrc.js index ea11972fa..d328c516e 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -41,18 +41,18 @@ module.exports = { binaryPath: 'android/app/build/outputs/apk/official/release/app-official-release.apk', build: - 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release -DuseDebugCertificate=yes -DminifyEnabled=no -DuploadCrashlyticsMappingFile=no --warning-mode all && cd ..', + 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release -DuseDebugCertificate=yes -DminifyEnabled=no -DuploadCrashlyticsMappingFile=no --no-daemon --warning-mode all && cd ..', launchArgs: {}, }, }, devices: { iosSimulator: { type: 'ios.simulator', - device: { type: 'iPhone 15', os: 'iOS 17.4' }, + device: { type: 'iPhone 15', os: 'iOS 17.5' }, }, androidEmulator: { type: 'android.emulator', - device: { avdName: 'Pixel_8_API_34' }, + device: { avdName: 'Pixel_6_API_28' }, }, attached: { type: 'android.attached', diff --git a/.github/workflows/testOnDevice.yml b/.github/workflows/testOnDevice.yml index d0c8ea828..fc1a9cb11 100644 --- a/.github/workflows/testOnDevice.yml +++ b/.github/workflows/testOnDevice.yml @@ -116,6 +116,17 @@ jobs: if: github.actor != 'dependabot[bot]' name: Android Emulator Tests runs-on: ubuntu-latest + defaults: + run: + working-directory: coopcycle-app + env: + GEOCODE_EARTH_API_KEY: ${{ secrets.GEOCODE_EARTH_API_KEY }} + STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_CONNECT_CLIENT_ID: ${{ secrets.STRIPE_CONNECT_CLIENT_ID }} + AWS_ENDPOINT: ${{ secrets.AWS_ENDPOINT }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} strategy: # Allow tests to continue on other devices if they fail on one device. fail-fast: false @@ -125,21 +136,65 @@ jobs: - 28 # - 33 steps: + # setup local coopcycle-web instance; FIXME: disabled for now as it's not enough space to run both; android build fails with 'No space left on device.' +# - uses: actions/checkout@v3 +# with: +# repository: coopcycle/coopcycle-web +# path: coopcycle-web +# - name: Create .env file +# run: cp .env.dist .env +# working-directory: coopcycle-web +# - name: Pull Docker images +# run: docker compose pull --ignore-pull-failures +# working-directory: coopcycle-web +# - name: Prepare OSRM data +# run: | +# docker compose run -T --rm osrm wget --no-check-certificate https://coopcycle-assets.sfo2.digitaloceanspaces.com/osm/paris-france.osm.pbf -O /data/data.osm.pbf +# docker compose run -T --rm osrm osrm-extract -p /opt/bicycle.lua /data/data.osm.pbf +# docker compose run -T --rm osrm osrm-partition /data/data.osrm +# docker compose run -T --rm osrm osrm-customize /data/data.osrm +# working-directory: coopcycle-web +# # Cypress GitHub Action uses npm ci, and it causes a "permission denied" error, +# # because it tries to remove the node_modules/ folder, which is mounted with root:root +# # We create the node_modules/ folder *BEFORE* starting the containers, +# # so that it can be removed without problems. +# - name: Create node_modules directory +# run: mkdir node_modules +# working-directory: coopcycle-web +# - name: Start Docker containers +# run: docker compose up -d +# working-directory: coopcycle-web +# - name: Wait for PHP-FPM +# run: until docker inspect --format='{{ .State.Health.Status }}' $(docker compose ps -q php) | grep -wq healthy; do sleep 5; done +# working-directory: coopcycle-web +# - name: Create database +# run: docker compose exec -T php bin/console doctrine:schema:create --env=test +# working-directory: coopcycle-web +# - name: Create typesense collections +# run: docker compose exec -T php bin/console typesense:create --env=test +# working-directory: coopcycle-web +# - name: Setup CoopCycle +# run: docker compose exec -T php bin/console coopcycle:setup --env=test +# working-directory: coopcycle-web + - uses: actions/checkout@v3 + with: + path: coopcycle-app - name: Free Disk Space (Ubuntu) uses: jlumbroso/free-disk-space@main with: # all of these default to true, but feel free to set to # "false" if necessary for your workflow + # https://github.com/jlumbroso/free-disk-space?tab=readme-ov-file#example android: false - # large-packages: true - # swap-storage: true - uses: actions/setup-node@v4 with: node-version: '20.x' - cache: 'yarn' + # Temporary disable yarn caching + # cache: 'yarn' + # cache-dependency-path: "coopcycle-app" - uses: actions/setup-java@v3 with: @@ -150,7 +205,7 @@ jobs: - name: Decode sentry.properties file uses: timheuer/base64-to-file@v1.2 with: - fileDir: "./android/" + fileDir: "./coopcycle-app/android/" fileName: "sentry.properties" encodedString: ${{ secrets.SENTRY_PROPERTIES_BASE64 }} @@ -208,6 +263,7 @@ jobs: run: node node_modules/.bin/detox build -c android.emu.release env: googleMapsApiKey: ${{ secrets.googleMapsApiKey }} + SENTRY_DISABLE_AUTO_UPLOAD: true - name: Disk usage (filesystem) run: df -h @@ -227,8 +283,8 @@ jobs: disable-animations: true script: | node node_modules/.bin/detox test -c android.emu.release --device-name="test" -l debug -# node node_modules/.bin/detox test -c android.att.release --device-name="test" --take-screenshots all -# node node_modules/.bin/detox test -c android.att.release --device-name="test" --take-screenshots manual -o e2e/screenshots.config.json + # node node_modules/.bin/detox test -c android.att.release --device-name="test" --take-screenshots all + # node node_modules/.bin/detox test -c android.att.release --device-name="test" --take-screenshots manual -o e2e/screenshots.config.json - name: List supported devices if: false # useful only for debugging diff --git a/README.md b/README.md index a72a37bd6..cfe038187 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ yarn ios Testing ------- -``` +```sh yarn test ``` @@ -131,15 +131,21 @@ Build the app and run tests: Android: -``` +```sh detox build -c android.emu.debug +``` + +```sh detox test -c android.emu.debug ``` iOS: -``` +```sh detox build -c ios.sim.debug +``` + +```sh detox test -c ios.sim.debug ``` diff --git a/e2e/.eslintrc.js b/e2e/.eslintrc.js index cf63872ed..2a6b8828a 100644 --- a/e2e/.eslintrc.js +++ b/e2e/.eslintrc.js @@ -3,6 +3,7 @@ module.exports = { afterAll: true, beforeAll: true, beforeEach: true, + expect: true, by: true, device: true, element: true, diff --git a/e2e/02_courier.spec.js b/e2e/02_courier.spec.js new file mode 100644 index 000000000..a6162e164 --- /dev/null +++ b/e2e/02_courier.spec.js @@ -0,0 +1,46 @@ +import { + authenticateWithCredentials, + connectToTestInstance, + symfonyConsole, +} from './utils'; + +const execSync = require('child_process').execSync; + +//FIXME; this test requires a local coopcycle-web instance, which is problematic to setup on CI (see testOnDevice.yml) +describe.skip('Courier', () => { + beforeEach(async () => { + symfonyConsole('coopcycle:fixtures:load -f cypress/fixtures/courier.yml'); + + if (device.getPlatform() === 'ios') { + // disable password autofill: https://github.com/wix/Detox/issues/3761 + execSync( + `plutil -replace restrictedBool.allowPasswordAutoFill.value -bool NO ~/Library/Developer/CoreSimulator/Devices/${device.id}/data/Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles/UserSettings.plist`, + ); + execSync( + `plutil -replace restrictedBool.allowPasswordAutoFill.value -bool NO ~/Library/Developer/CoreSimulator/Devices/${device.id}/data/Library/UserConfigurationProfiles/EffectiveUserSettings.plist`, + ); + execSync( + `plutil -replace restrictedBool.allowPasswordAutoFill.value -bool NO ~/Library/Developer/CoreSimulator/Devices/${device.id}/data/Library/UserConfigurationProfiles/PublicInfo/PublicEffectiveUserSettings.plist`, + ); + } + await device.reloadReactNative(); + await connectToTestInstance(); + }); + + it(`should be able to login and see tasks`, async () => { + await authenticateWithCredentials('jane', '12345678'); + + if (device.getPlatform() === 'android') { + // dismiss BACKGROUND_PERMISSION_DISCLOSURE alert + await element(by.text('CLOSE')).tap(); + } + + await expect(element(by.id('messengerTabMap'))).toBeVisible(); + await expect(element(by.id('messengerTabList'))).toBeVisible(); + + await element(by.id('messengerTabList')).tap(); + + await expect(element(by.id('task:0'))).toBeVisible(); + await expect(element(by.id('task:1'))).toBeVisible(); + }); +}); diff --git a/e2e/utils.js b/e2e/utils.js index 4f5915d1d..0528c2ad6 100644 --- a/e2e/utils.js +++ b/e2e/utils.js @@ -1,3 +1,17 @@ +const execSync = require('child_process').execSync; +const os = require('os'); + +export const COMMAND_PREFIX = "cd ../coopcycle-web && docker compose exec -T php" + +export const symfonyConsole = (command) => { + const prefix = COMMAND_PREFIX + let cmd = `bin/console ${ command } --env="test"` + if (prefix) { + cmd = `${ prefix } ${ cmd }` + } + execSync(cmd) +} + export const connectToDemo = async () => { await expect(element(by.id('chooseCityBtn'))).toBeVisible(); await element(by.id('chooseCityBtn')).tap(); @@ -14,10 +28,46 @@ export const connectToDemo = async () => { } catch (e) {} }; +const getLocalIpAddress = () => { + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === 'IPv4' && !iface.internal) { + return iface.address; + } + } + } + return null; +}; + +export const connectToTestInstance = async () => { + await expect(element(by.id('chooseCityBtn'))).toBeVisible(); + await element(by.id('chooseCityBtn')).tap(); + + await expect(element(by.id('moreServerOptions'))).toBeVisible(); + await element(by.id('moreServerOptions')).tap(); + + await element(by.id('customServerURL')).typeText(`${getLocalIpAddress()}:9080\n`); + + try { + // We deliberately add "\n" to hide the keyboard + // The tap below shouldn't be necessary + await element(by.id('submitCustomServer')).tap(); + } catch (e) {} +}; + export const authenticateWithCredentials = async (username, password) => { await expect(element(by.id('menuBtn'))).toBeVisible(); await element(by.id('menuBtn')).tap(); + await element(by.id('drawerAccountBtn')).tap(); + //FIXME: for some reason drawer menu does not close after the first tap on Android + if (device.getPlatform() === 'android') { + const attrs = await element(by.id('drawerAccountBtn')).getAttributes() + if (attrs.visible) { + await element(by.id('drawerAccountBtn')).tap(); + } + } await element(by.id('loginUsername')).typeText(`${username}\n`); await element(by.id('loginPassword')).typeText(`${password}\n`); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b33f5750c..6936035c1 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1861,7 +1861,7 @@ SPEC CHECKSUMS: StripePaymentSheet: a25d920bb3bb5e2580696476482dc7df9cb5e4e2 StripePaymentsUI: 66088abec88754bbdd522ef227dfdbb2265a653e StripeUICore: b193c7d35e9cd1b04bc9ed4a6fb8c548fcee83fa - Yoga: 9e6a04eacbd94f97d94577017e9f23b3ab41cf6c + Yoga: a716eea57d0d3430219c0a5a233e1e93ee931eb7 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 PODFILE CHECKSUM: d949c9fd9c4ce5f3ec67d52ca1f4a1ca13072560 diff --git a/package.json b/package.json index 48b9137bc..254d2074c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@tabler/icons-react-native": "^3.2.0", "@turf/turf": "^6.5.0", "abortcontroller-polyfill": "^1.7.5", + "async-mutex": "^0.5.0", "axios": "^1.6.8", "buffer": "^6.0.3", "centrifuge": "^2.8.4", diff --git a/src/navigation/courier/TaskListPage.js b/src/navigation/courier/TaskListPage.js index e15e0995a..4db305caa 100644 --- a/src/navigation/courier/TaskListPage.js +++ b/src/navigation/courier/TaskListPage.js @@ -1,20 +1,18 @@ -import React, { Component } from 'react'; -import { StyleSheet, View } from 'react-native'; -import { connect } from 'react-redux'; +import React from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { useSelector } from 'react-redux'; -import { withTranslation } from 'react-i18next'; import DateSelectHeader from '../../components/DateSelectHeader'; import TapToRefresh from '../../components/TapToRefresh'; import TaskList from '../../components/TaskList'; import { navigateToCompleteTask, navigateToTask } from '../../navigation/utils'; import { - loadTasks, selectFilteredTasks, - selectIsTasksRefreshing, selectTaskSelectedDate, selectTasksWithColor, } from '../../redux/Courier'; import { doneIconName } from '../task/styles/common'; +import { useGetMyTasksQuery } from '../../redux/api/slice'; const styles = StyleSheet.create({ containerEmpty: { @@ -29,108 +27,67 @@ const styles = StyleSheet.create({ }, }); -class TaskListPage extends Component { - completeSelectedTasks(selectedTasks) { - if (selectedTasks.length > 1) { - navigateToCompleteTask( - this.props.navigation, - this.props.route, - null, - selectedTasks, - true, - ); - } else if (selectedTasks.length === 1) { - navigateToCompleteTask( - this.props.navigation, - this.props.route, - selectedTasks[0], - [], - true, - ); - } - } +const allowToSelect = task => { + return task.status !== 'DONE'; +}; - allowToSelect(task) { - return task.status !== 'DONE'; +export default function TaskListPage({ navigation, route }) { + const selectedDate = useSelector(selectTaskSelectedDate); + const tasks = useSelector(selectFilteredTasks); + const tasksWithColor = useSelector(selectTasksWithColor); + + const containerStyle = [styles.container]; + if (tasks.length === 0) { + containerStyle.push(styles.containerEmpty); } - render() { - const { tasks, tasksWithColor, selectedDate } = this.props; + const { isFetching, refetch } = useGetMyTasksQuery(selectedDate, { + refetchOnFocus: true, + }); - const containerStyle = [styles.container]; - if (tasks.length === 0) { - containerStyle.push(styles.containerEmpty); + const completeSelectedTasks = selectedTasks => { + if (selectedTasks.length > 1) { + navigateToCompleteTask(navigation, route, null, selectedTasks, true); + } else if (selectedTasks.length === 1) { + navigateToCompleteTask(navigation, route, selectedTasks[0], [], true); } - - return ( - - - {tasks.length > 0 && ( - - navigateToCompleteTask( - this.props.navigation, - this.props.route, - task, - [], - true, - ) - } - onSwipeRight={task => - navigateToCompleteTask( - this.props.navigation, - this.props.route, - task, - [], - false, - ) - } - swipeOutLeftEnabled={task => task.status !== 'DONE'} - swipeOutRightEnabled={task => task.status !== 'DONE'} - onTaskClick={task => - navigateToTask( - this.props.navigation, - this.props.route, - task, - tasks, - ) - } - refreshing={this.props.isRefreshing} - onRefresh={() => this.props.refreshTasks(selectedDate)} - allowMultipleSelection={task => this.allowToSelect(task)} - multipleSelectionIcon={doneIconName} - onMultipleSelectionAction={selectedTasks => - this.completeSelectedTasks(selectedTasks) - } - /> - )} - {tasks.length === 0 && ( - this.props.loadTasks(selectedDate)} /> - )} - - ); - } -} - -function mapStateToProps(state) { - return { - tasks: selectFilteredTasks(state), - tasksWithColor: selectTasksWithColor(state), - selectedDate: selectTaskSelectedDate(state), - isRefreshing: selectIsTasksRefreshing(state), }; -} -function mapDispatchToProps(dispatch) { - return { - loadTasks: selectedDate => dispatch(loadTasks(selectedDate)), - refreshTasks: selectedDate => dispatch(loadTasks(selectedDate, true)), - }; + return ( + + + {tasks.length > 0 && ( + + navigateToCompleteTask(navigation, route, task, [], true) + } + onSwipeRight={task => + navigateToCompleteTask(navigation, route, task, [], false) + } + swipeOutLeftEnabled={task => task.status !== 'DONE'} + swipeOutRightEnabled={task => task.status !== 'DONE'} + onTaskClick={task => navigateToTask(navigation, route, task, tasks)} + refreshing={isFetching} + onRefresh={() => refetch()} + allowMultipleSelection={task => allowToSelect(task)} + multipleSelectionIcon={doneIconName} + onMultipleSelectionAction={selectedTasks => + completeSelectedTasks(selectedTasks) + } + /> + )} + {tasks.length === 0 && ( + <> + + refetch()} /> + + )} + + ); } - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(withTranslation()(TaskListPage)); diff --git a/src/navigation/courier/TasksPage.js b/src/navigation/courier/TasksPage.js index d7c199717..375dc9340 100644 --- a/src/navigation/courier/TasksPage.js +++ b/src/navigation/courier/TasksPage.js @@ -1,34 +1,95 @@ import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; -import React, { Component } from 'react'; +import React, { Component, useMemo } from 'react'; import { withTranslation } from 'react-i18next'; -import { InteractionManager, Platform, StyleSheet, View } from 'react-native'; +import { + ActivityIndicator, + InteractionManager, + Platform, + StyleSheet, + View, +} from 'react-native'; import RNPinScreen from 'react-native-pin-screen'; -import { connect } from 'react-redux'; +import { connect, useSelector } from 'react-redux'; import DateSelectHeader from '../../components/DateSelectHeader'; import TasksMapView from '../../components/TasksMapView'; import { navigateToTask } from '../../navigation/utils'; import { - loadTasks, selectFilteredTasks, selectKeepAwake, selectTaskSelectedDate, } from '../../redux/Courier'; -import { selectIsCentrifugoConnected } from '../../redux/App/selectors'; +import { + selectIsCentrifugoConnected, + selectSettingsLatLng, +} from '../../redux/App/selectors'; import { connect as connectCentrifugo } from '../../redux/middlewares/CentrifugoMiddleware/actions'; +import { useGetMyTasksQuery } from '../../redux/api/slice'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + activityContainer: { + position: 'absolute', + left: 0, + right: 0, + display: 'flex', + alignItems: 'center', + margin: 8, + }, + activityIndicator: { + padding: 8, + backgroundColor: 'white', + borderRadius: 8, + }, +}); + +function TaskMapPage({ navigation, route }) { + const selectedDate = useSelector(selectTaskSelectedDate); + const tasks = useSelector(selectFilteredTasks); + const latlng = useSelector(selectSettingsLatLng); + const mapCenter = useMemo(() => { + return latlng.split(',').map(parseFloat); + }, [latlng]); + + const { isFetching } = useGetMyTasksQuery(selectedDate, { + refetchOnFocus: true, + }); + + return ( + + + + + navigateToTask(navigation, route, task, tasks) + } + /> + {isFetching ? ( + + + + ) : null} + + + ); +} class TasksPage extends Component { constructor(props) { super(props); this.state = { - task: null, - polyline: [], isFocused: false, }; - - this.refreshTasks = this.refreshTasks.bind(this); } enableKeepAwake() { @@ -79,55 +140,31 @@ class TasksPage extends Component { _bootstrap() { InteractionManager.runAfterInteractions(() => { - this.refreshTasks(this.props.selectedDate); - if (!this.props.selectIsCentrifugoConnected) { + if (!this.props.isCentrifugoConnected) { this.props.connectCent(); } }); } - refreshTasks(selectedDate) { - this.props.loadTasks(selectedDate); - } - render() { - const { tasks } = this.props; - return ( - - - - navigateToTask(this.props.navigation, this.props.route, task, tasks) - } - /> - + ); } } -const styles = StyleSheet.create({ - container: { - flex: 1, - }, -}); - function mapStateToProps(state) { return { - tasks: selectFilteredTasks(state), - selectedDate: selectTaskSelectedDate(state), keepAwake: selectKeepAwake(state), isCentrifugoConnected: selectIsCentrifugoConnected(state), - httpClient: state.app.httpClient, - mapCenter: state.app.settings.latlng.split(',').map(parseFloat), }; } function mapDispatchToProps(dispatch) { return { - loadTasks: selectedDate => dispatch(loadTasks(selectedDate)), connectCent: () => dispatch(connectCentrifugo()), }; } diff --git a/src/redux/App/selectors.js b/src/redux/App/selectors.js index 022d12f43..330fb0705 100644 --- a/src/redux/App/selectors.js +++ b/src/redux/App/selectors.js @@ -192,3 +192,5 @@ export const selectNotificationsToDisplay = createSelector( } }), ); + +export const selectSettingsLatLng = state => state.app.settings.latlng; diff --git a/src/redux/Courier/index.js b/src/redux/Courier/index.js index b61ef4bd5..8de614af8 100644 --- a/src/redux/Courier/index.js +++ b/src/redux/Courier/index.js @@ -56,7 +56,6 @@ import { selectIsTaskCompleteFailure, selectIsTasksLoading, selectIsTasksLoadingFailure, - selectIsTasksRefreshing, selectKeepAwake, selectPictures, selectSignatureScreenFirst, @@ -111,7 +110,6 @@ export { selectIsTaskCompleteFailure, selectIsTasksLoading, selectIsTasksLoadingFailure, - selectIsTasksRefreshing, selectKeepAwake, selectPictures, selectSignatureScreenFirst, diff --git a/src/redux/Courier/taskEntityReducer.js b/src/redux/Courier/taskEntityReducer.js index c6057401a..f740ec75e 100644 --- a/src/redux/Courier/taskEntityReducer.js +++ b/src/redux/Courier/taskEntityReducer.js @@ -32,6 +32,7 @@ import { REPORT_INCIDENT_SUCCESS, REPORT_INCIDENT_FAILURE, } from './taskActions'; +import { apiSlice } from '../api/slice' /* * Intital state shape for the task entity reducer @@ -40,7 +41,6 @@ const tasksEntityInitialState = { loadTasksFetchError: false, // Error object describing the error completeTaskFetchError: false, // Error object describing the error isFetching: false, // Flag indicating active HTTP request - isRefreshing: false, date: moment().format('YYYY-MM-DD'), // YYYY-MM-DD updatedAt: moment().toString(), items: { @@ -122,27 +122,6 @@ export const tasksEntityReducer = ( isFetching: true, }; - case LOAD_TASKS_REQUEST: - return { - ...state, - loadTasksFetchError: false, - completeTaskFetchError: false, - // This is the date that is selected in the UI - date: action.payload.date - ? action.payload.date.format('YYYY-MM-DD') - : moment().format('YYYY-MM-DD'), - isFetching: !action.payload.refresh, - isRefreshing: action.payload.refresh, - }; - - case LOAD_TASKS_FAILURE: - return { - ...state, - loadTasksFetchError: action.payload || action.error, - isFetching: false, - isRefreshing: false, - }; - case START_TASK_FAILURE: case MARK_TASK_DONE_FAILURE: case MARK_TASK_FAILED_FAILURE: @@ -168,20 +147,6 @@ export const tasksEntityReducer = ( ), } - - case LOAD_TASKS_SUCCESS: - return { - ...state, - loadTasksFetchError: false, - isFetching: false, - isRefreshing: false, - updatedAt: action.payload.updatedAt.toString(), - items: { - ...state.items, - [action.payload.date]: action.payload.items, - }, - }; - case START_TASK_SUCCESS: case MARK_TASK_DONE_SUCCESS: case MARK_TASK_FAILED_SUCCESS: @@ -295,6 +260,74 @@ export const tasksEntityReducer = ( }; } + switch (true) { + //using axios; FIXME: migrate to rtk query + case action.type === LOAD_TASKS_REQUEST: { + return { + ...state, + loadTasksFetchError: false, + completeTaskFetchError: false, + // This is the date that is selected in the UI + date: action.payload.date + ? action.payload.date.format('YYYY-MM-DD') + : moment().format('YYYY-MM-DD'), + isFetching: true, + }; + } + //using rtk query + case apiSlice.endpoints.getMyTasks.matchPending(action): + return { + ...state, + loadTasksFetchError: false, + completeTaskFetchError: false, + // This is the date that is selected in the UI + date: action.meta.arg.originalArgs.format('YYYY-MM-DD'), + // isFetching: true, # don't set isFetching flag to prevent global loading spinner for rtk query requests + }; + + //using axios; FIXME: migrate to rtk query + case action.type === LOAD_TASKS_SUCCESS: { + return { + ...state, + loadTasksFetchError: false, + isFetching: false, + updatedAt: action.payload.updatedAt.toString(), + items: { + ...state.items, + [action.payload.date]: action.payload.items, + }, + }; + } + //using rtk query + case apiSlice.endpoints.getMyTasks.matchFulfilled(action): + return { + ...state, + loadTasksFetchError: false, + isFetching: false, + updatedAt: action.payload.updatedAt.toString(), + items: { + ...state.items, + [action.payload.date]: action.payload.items, + }, + }; + + //using axios; FIXME: migrate to rtk query + case action.type === LOAD_TASKS_FAILURE: { + return { + ...state, + loadTasksFetchError: action.payload || action.error, + isFetching: false, + }; + } + //using rtk query + case apiSlice.endpoints.getMyTasks.matchRejected(action): + return { + ...state, + loadTasksFetchError: action.payload || action.error, + isFetching: false, + }; + } + return state; }; diff --git a/src/redux/Courier/taskMiddlewares.js b/src/redux/Courier/taskMiddlewares.js index 5ae1f2e87..02f15425d 100644 --- a/src/redux/Courier/taskMiddlewares.js +++ b/src/redux/Courier/taskMiddlewares.js @@ -5,6 +5,7 @@ import { LOGOUT_SUCCESS, addNotification } from '../App/actions'; import { LOAD_TASKS_SUCCESS } from './taskActions'; import { selectTasks } from './taskSelectors'; import { EVENT as EVENT_TASK_COLLECTION } from '../../domain/TaskCollection'; +import { apiSlice } from '../api/slice' export const ringOnTaskListUpdated = ({ getState, dispatch }) => { return next => action => { @@ -15,6 +16,7 @@ export const ringOnTaskListUpdated = ({ getState, dispatch }) => { // Avoid ringing on first load if ( action.type === LOAD_TASKS_SUCCESS || + apiSlice.endpoints.getMyTasks.matchFulfilled(action) || action.type === 'persist/REHYDRATE' ) { return next(action); diff --git a/src/redux/Courier/taskSelectors.js b/src/redux/Courier/taskSelectors.js index b78049e20..54936e588 100644 --- a/src/redux/Courier/taskSelectors.js +++ b/src/redux/Courier/taskSelectors.js @@ -13,8 +13,6 @@ import { taskUtils } from '../../coopcycle-frontend-js/logistics/redux'; /* Simple Selectors */ export const selectTaskSelectedDate = state => state.ui.tasks.selectedDate; export const selectIsTasksLoading = state => state.entities.tasks.isFetching; -export const selectIsTasksRefreshing = state => - state.entities.tasks.isRefreshing; export const selectIsTasksLoadingFailure = state => state.entities.tasks.loadTasksFetchError; export const selectIsTaskCompleteFailure = state => diff --git a/src/redux/api/baseQuery.js b/src/redux/api/baseQuery.js new file mode 100644 index 000000000..fd7e993dd --- /dev/null +++ b/src/redux/api/baseQuery.js @@ -0,0 +1,128 @@ +import VersionNumber from 'react-native-version-number'; +import { fetchBaseQuery } from '@reduxjs/toolkit/dist/query/react'; +import { selectBaseURL, selectUser } from '../App/selectors'; +import { Mutex } from 'async-mutex'; +import qs from 'qs'; +import AppUser from '../../AppUser'; +import { logout } from '../App/actions'; +import { setUser } from '../middlewares/HttpMiddleware' + +const guestCheckoutEndpoints = [ + 'getOrderValidate', + 'getOrderTiming', + 'updateOrder', +]; + +const appVersion = + VersionNumber.bundleIdentifier + + '@' + + VersionNumber.appVersion + + ' (' + + VersionNumber.buildVersion + + ')'; + +// create a new mutex +const mutex = new Mutex(); + +const buildBaseQuery = (baseUrl, anonymous = false) => { + return fetchBaseQuery({ + baseUrl, + prepareHeaders: (headers, { getState, endpoint }) => { + headers.set('X-Application-Version', appVersion); + + if (!anonymous) { + const user = selectUser(getState()); + if (user) { + headers.set('Authorization', `Bearer ${user.token}`); + } else if (guestCheckoutEndpoints.includes(endpoint)) { + //TODO; to be implemented in https://github.com/coopcycle/coopcycle-app/issues/1756 + // const orderAccessToken = selectOrderAccessToken(getState()) + // + // if (orderAccessToken) { + // headers.set('Authorization', `Bearer ${orderAccessToken}`) + // } + } + } + + return headers; + }, + jsonContentType: 'application/ld+json', + timeout: 30000, + }); +}; + +//based on https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#automatic-re-authorization-by-extending-fetchbasequery +export const baseQueryWithReauth = async (args, api, extraOptions) => { + // wait until the mutex is available without locking it + await mutex.waitForUnlock(); + + const { getState } = api; + + const baseUrl = selectBaseURL(getState()) + '/'; + const baseQuery = buildBaseQuery(baseUrl); + + let result = await baseQuery(args, api, extraOptions); + + if (result.error && result.error.status === 401) { + const user = selectUser(getState()); + + if (!user) { + return result; + } + + const refreshToken = user.refreshToken; + + if (mutex.isLocked()) { + // wait for the mutex release, i.e. wait that another request that needed a fresh token gets the new token for us and update the state accordingly + await mutex.waitForUnlock(); + result = await baseQuery(args, api, extraOptions); + } else { + const release = await mutex.acquire(); + try { + // try to get a new token + const refreshResult = await buildBaseQuery(baseUrl, true)( + { + url: '/api/token/refresh', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: qs.stringify({ + refresh_token: refreshToken, + }), + }, + api, + extraOptions, + ); + if (refreshResult.data) { + const { token, refresh_token } = refreshResult.data; + + const { username, email, roles, enabled } = user; + + const updUser = new AppUser( + username, + email, + token, + roles, + refresh_token, + enabled, + ); + + // store the new token + api.dispatch(setUser(updUser)); + await user.save(); + console.log('Credentials saved!') + + // retry the initial query + result = await baseQuery(args, api, extraOptions); + } else { + api.dispatch(logout()); + } + } finally { + // release must be called once the mutex should be released again. + release(); + } + } + } + return result; +}; diff --git a/src/redux/api/slice.js b/src/redux/api/slice.js new file mode 100644 index 000000000..b50533fcb --- /dev/null +++ b/src/redux/api/slice.js @@ -0,0 +1,46 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; +import { baseQueryWithReauth } from './baseQuery'; + +// Define our single API slice object +export const apiSlice = createApi({ + reducerPath: 'api', + baseQuery: baseQueryWithReauth, + // The "endpoints" represent operations and requests for this server + // nodeId is passed in JSON-LD '@id' key, https://www.w3.org/TR/2014/REC-json-ld-20140116/#node-identifiers + endpoints: builder => ({ + subscriptionGenerateOrders: builder.mutation({ + query: date => ({ + url: 'api/recurrence_rules/generate_orders', + params: { + date: date.format('YYYY-MM-DD'), + }, + method: 'POST', + body: {}, + }), + }), + getMyTasks: builder.query({ + query: date => `api/me/tasks/${date.format('YYYY-MM-DD')}`, + }), + getOrderTiming: builder.query({ + query: nodeId => `${nodeId}/timing`, + }), + getOrderValidate: builder.query({ + query: nodeId => `${nodeId}/validate`, + }), + updateOrder: builder.mutation({ + query: ({ nodeId, ...patch }) => ({ + url: nodeId, + method: 'PUT', + body: patch, + }), + }), + }), +}); + +// Export the auto-generated hook for the query endpoints +export const { + useSubscriptionGenerateOrdersMutation, + useGetMyTasksQuery, + useGetOrderTimingQuery, + useUpdateOrderMutation, +} = apiSlice; diff --git a/src/redux/middlewares/HttpMiddleware/index.js b/src/redux/middlewares/HttpMiddleware/index.js index dacb76fc5..0fa766c54 100644 --- a/src/redux/middlewares/HttpMiddleware/index.js +++ b/src/redux/middlewares/HttpMiddleware/index.js @@ -7,7 +7,7 @@ import { SET_HTTP_CLIENT, SET_USER } from '../../App/actions'; import { selectIsAuthenticated } from '../../App/selectors'; const setHttpClient = createAction(SET_HTTP_CLIENT); -const setUser = createAction(SET_USER); +export const setUser = createAction(SET_USER); export default ({ getState, dispatch }) => { return next => action => { diff --git a/src/redux/reducers.js b/src/redux/reducers.js index a6a71e99a..9b9941faf 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -38,6 +38,7 @@ import appTaskEntityReducers from './logistics/taskEntityReducers'; import appTaskListEntityReducers from './logistics/taskListEntityReducers'; import appLastmileUiReducers from './logistics/uiReducers'; import { createTaskItemsTransform } from './util'; +import { apiSlice } from './api/slice' const taskEntitiesPersistConfig = { key: 'entities.items', @@ -166,6 +167,7 @@ export default combineReducers({ tasks: persistReducer(taskEntitiesPersistConfig, tasksEntityReducer), }), app: persistReducer(appPersistConfig, appReducer), + [apiSlice.reducerPath]: apiSlice.reducer, account: accountReducer, restaurant: persistReducer(restaurantPersistConfig, restaurantReducer), store: storeReducer, diff --git a/src/redux/setupListenersReactNative.js b/src/redux/setupListenersReactNative.js new file mode 100644 index 000000000..396f45fa1 --- /dev/null +++ b/src/redux/setupListenersReactNative.js @@ -0,0 +1,60 @@ +import NetInfo from '@react-native-community/netinfo'; +import { AppState } from 'react-native'; + +let initialized = false; + +let appStateSubscription = null; +let appState = AppState.currentState; + +let netInfoUnsubscribe = null; + +export function setupListenersReactNative( + dispatch, + { onFocus, onFocusLost, onOffline, onOnline }, +) { + const handleFocus = () => dispatch(onFocus()); + const handleFocusLost = () => dispatch(onFocusLost()); + const handleOnline = () => dispatch(onOnline()); + const handleOffline = () => dispatch(onOffline()); + + const _handleAppStateChange = nextAppState => { + const foreground = !!( + appState.match(/inactive|background/) && nextAppState === 'active' + ); + + if (foreground) handleFocus(); + else handleFocusLost(); + + appState = nextAppState; + }; + + const _handleNetInfoChange = state => { + const { isInternetReachable } = state; + + if (isInternetReachable) handleOnline(); + else handleOffline(); + }; + + if (!initialized) { + appStateSubscription = AppState.addEventListener( + 'change', + _handleAppStateChange, + ); + netInfoUnsubscribe = NetInfo.addEventListener(_handleNetInfoChange); + initialized = true; + } + + const unsubscribe = () => { + if (appStateSubscription) { + appStateSubscription.remove(); + appStateSubscription = null; + } + if (netInfoUnsubscribe) { + netInfoUnsubscribe(); + netInfoUnsubscribe = null; + } + initialized = false; + }; + + return unsubscribe; +} diff --git a/src/redux/store.js b/src/redux/store.js index 12bb7962b..9e8094634 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -18,12 +18,16 @@ import CentrifugoMiddleware from './middlewares/CentrifugoMiddleware'; import { filterExpiredCarts } from './Checkout/middlewares'; import SoundMiddleware from './middlewares/SoundMiddleware'; import { notifyOnNewOrderCreated } from './Restaurant/middlewares'; +import { apiSlice } from './api/slice'; +import { setupListeners } from '@reduxjs/toolkit/query'; +import { setupListenersReactNative } from './setupListenersReactNative'; const middlewares = [ thunk, ReduxAsyncQueue, NetInfoMiddleware, HttpMiddleware, + apiSlice.middleware, PushNotificationMiddleware, CentrifugoMiddleware, SentryMiddleware, @@ -52,6 +56,13 @@ const middlewaresProxy = middlewaresList => { const store = createStore(reducers, middlewaresProxy(middlewares)); +// enable support for refetchOnFocus and refetchOnReconnect behaviors +// they are disabled by default and need to be enabled explicitly for each hook/action +// https://redux-toolkit.js.org/rtk-query/api/createApi#refetchonfocus +// https://redux-toolkit.js.org/rtk-query/api/setupListeners +setupListeners(store.dispatch); +setupListenersReactNative(store.dispatch, apiSlice.internalActions); + export default store; export const persistor = persistStore(store); diff --git a/yarn.lock b/yarn.lock index dcb9e53cd..815d116f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6600,6 +6600,13 @@ async-limiter@~1.0.0: resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== +async-mutex@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.5.0.tgz#353c69a0b9e75250971a64ac203b0ebfddd75482" + integrity sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA== + dependencies: + tslib "^2.4.0" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"