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"