-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Description
Before opening, please confirm:
- I have searched for duplicate or closed issues and discussions.
- I have read the guide for submitting bug reports.
- I have done my best to include a minimal, self-contained set of instructions for consistently reproducing the issue.
JavaScript Framework
React, React Native
Amplify APIs
Authentication, GraphQL API, DataStore
Amplify Categories
auth, api
Environment information
Details
System:
OS: Linux 6.5 Arch Linux
CPU: (16) x64 AMD Ryzen 7 3700X 8-Core Processor
Memory: 4.71 GB / 31.25 GB
Container: Yes
Shell: 5.9 - /bin/zsh
Binaries:
Node: 16.13.2 - ~/.nvm/versions/node/v16.13.2/bin/node
Yarn: 1.22.15 - ~/.nvm/versions/node/v16.13.2/bin/yarn
npm: 8.1.2 - ~/.nvm/versions/node/v16.13.2/bin/npm
Browsers:
Firefox: 118.0.2
npmPackages:
@aws-amplify/ui-react: ^3.6.0 => 3.6.0
@aws-amplify/ui-react-internal: undefined ()
@aws-amplify/ui-react-legacy: undefined ()
@babel/cli: ^7.21.5 => 7.21.5
@babel/core: ^7.21.8 => 7.21.8 (7.12.3, 7.12.17)
@babel/preset-env: ^7.21.5 => 7.21.5 (7.12.1)
@cypress/react: ^5.10.3 => 5.10.3
@cypress/webpack-dev-server: ^1.7.0 => 1.7.0
@date-io/date-fns: ^2.11.0 => 2.11.0 (1.1.0)
@date-io/moment: ^2.11.0 => 2.11.0
@emotion/react: ^11.4.1 => 11.10.4
@emotion/styled: ^11.3.0 => 11.3.0
@fast-csv/format: ^4.3.5 => 4.3.5
@fontsource/roboto: ^4.5.1 => 4.5.1
@mui/icons-material: ^5.11.11 => 5.11.11
@mui/lab: ^5.0.0-alpha.50 => 5.0.0-alpha.50
@mui/material: ^5.0.3 => 5.0.3
@mui/styled-engine-sc: ^5.0.3 => 5.0.3
@mui/styles: ^5.0.1 => 5.0.1
@peculiar/webcrypto: ^1.2.2 => 1.2.2
@reduxjs/toolkit: ^1.6.1 => 1.6.1
@reduxjs/toolkit-query: 1.0.0
@reduxjs/toolkit-query-react: 1.0.0
@testing-library/cypress: ^8.0.2 => 8.0.2
@testing-library/jest-dom: ^5.15.0 => 5.15.0
@testing-library/react: ^12.1.2 => 12.1.2
@testing-library/user-event: ^13.5.0 => 13.5.0
@types/autosuggest-highlight: ^3.2.0 => 3.2.0
@types/jest: ^29.0.1 => 29.0.1
@types/lodash: ^4.14.185 => 4.14.185
@types/node: ^18.7.18 => 18.7.18 (14.18.28)
@types/react: ^17.0.49 => 17.0.49
@types/react-dom: ^18.0.6 => 18.0.6
@types/react-linkify: ^1.0.1 => 1.0.1
@types/react-router-dom: ^5.3.3 => 5.3.3
@types/uuid: ^8.3.4 => 8.3.4
@types/validator: ^13.7.17 => 13.7.17
@welldone-software/why-did-you-render: ^6.2.3 => 6.2.3
autosuggest-highlight: ^3.1.1 => 3.1.1
aws-amplify: ^4.3.46 => 4.3.46
axios: ^0.21.1 => 0.21.4 (0.26.0)
canvas: ^2.8.0 => 2.8.0
css-mediaquery: ^0.1.2 => 0.1.2
cypress: ^9.0.0 => 9.0.0
cypress-localstorage-commands: ^1.7.0 => 1.7.0
date-fns: ^2.23.0 => 2.25.0 (2.0.0-alpha.27)
downshift: ^6.1.7 => 6.1.7
faker: ^5.5.3 => 5.5.3
framer-motion: ^4.1.17 => 4.1.17
immutability-helper: ^3.1.1 => 3.1.1
intersection-observer: ^0.12.0 => 0.12.0
jest-canvas-mock: ^2.3.1 => 2.3.1
jest-fetch-mock: ^3.0.3 => 3.0.3
libphonenumber-js: ^1.9.25 => 1.9.25
libphonenumber-js-core: 1.0.0
libphonenumber-js-max: 1.0.0
libphonenumber-js-min: 1.0.0
libphonenumber-js-mobile: 1.0.0
lodash: ^4.17.21 => 4.17.21 (3.10.1)
match-sorter: ^6.3.0 => 6.3.0
material-table: ^1.69.3 => 1.69.3
moment: ^2.29.4 => 2.29.4
moment-timezone: ^0.5.35 => 0.5.35
notistack: ^2.0.2 => 2.0.2
prettier: ^2.8.4 => 2.8.4
prop-types: ^15.7.2 => 15.8.1 (15.6.2)
react: ^17.0.2 => 17.0.2
react-cropper: ^2.1.8 => 2.1.8
react-dom: ^17.0.2 => 17.0.2
react-easy-crop: ^4.0.1 => 4.0.1
react-helmet: ^6.1.0 => 6.1.0
react-horizontal-scrolling-menu: ^3.2.3 => 3.2.3
react-idle-timer: ^4.6.4 => 4.6.4
react-intersection-observer: ^8.33.1 => 8.33.1
react-linkify: ^1.0.0-alpha => 1.0.0-alpha
react-moment: ^1.1.1 => 1.1.1
react-notifications-component: ^3.1.0 => 3.1.0
react-redux: ^7.2.4 => 7.2.4
react-router: ^5.2.0 => 5.2.0
react-router-dom: ^5.2.0 => 5.2.0
react-scripts: ^4.0.3 => 4.0.3
react-waypoint: ^10.1.0 => 10.1.0
redux: ^4.1.1 => 4.1.1
redux-saga: ^1.1.3 => 1.1.3
redux-saga-test-plan: ^4.0.3 => 4.0.3
redux-saga/effects: undefined ()
redux-toolkit: ^1.1.2 => 1.1.2
seedrandom: ^3.0.5 => 3.0.5
socket.io-client: ^4.1.3 => 4.1.3
styled-components: ^5.3.1 => 5.3.1
styled-components/macro: undefined ()
styled-components/native: undefined ()
styled-components/primitives: undefined ()
tss-react: ^4.6.0 => 4.6.0
typescript: ^4.8.3 => 4.8.3
uuid: ^8.3.2 => 8.3.2 (3.4.0, 3.3.2, 7.0.3)
uuid-base62: ^0.1.0 => 0.1.0
validator: ^13.9.0 => 13.9.0
web-vitals: ^1.0.1 => 1.1.2
workbox-background-sync: ^5.1.4 => 5.1.4
workbox-broadcast-update: ^5.1.4 => 5.1.4
workbox-cacheable-response: ^5.1.4 => 5.1.4
workbox-core: ^5.1.4 => 5.1.4
workbox-expiration: ^5.1.4 => 5.1.4
workbox-google-analytics: ^5.1.4 => 5.1.4
workbox-navigation-preload: ^5.1.4 => 5.1.4
workbox-precaching: ^5.1.4 => 5.1.4
workbox-range-requests: ^5.1.4 => 5.1.4
workbox-routing: ^5.1.4 => 5.1.4
workbox-strategies: ^5.1.4 => 5.1.4
workbox-streams: ^5.1.4 => 5.1.4
npmGlobalPackages:
@aws-amplify/cli: 12.2.3
@bubblewrap/cli: 1.18.1
@ionic/cli: 6.20.3
amplify-cli: 1.0.0
aws-cdk: 2.100.0
cordova: 11.1.0
corepack: 0.10.0
create-react-native-app: 3.9.0
deadfile: 2.0.1
docsify-cli: 4.4.4
eas-cli: 5.2.0
graphql-language-service-cli: 3.3.16
jsonminify: 0.4.2
mjson: 0.4.2
native-run: 1.7.1
npm: 8.1.2
prebuild-install: 7.1.1
react_app: 0.1.0
react-dom: 17.0.2
react-js-to-ts: 1.4.0
react: 18.2.0
serve: 14.2.0
sharp-cli: 4.1.1
ts-node: 10.9.1
typescript: 4.8.2
uglify-js: 3.17.4
uglifyjs: 2.4.11
Details
``` System: OS: Linux 6.5 Arch Linux CPU: (16) x64 AMD Ryzen 7 3700X 8-Core Processor Memory: 4.42 GB / 31.25 GB Container: Yes Shell: 5.9 - /bin/zsh Binaries: Node: 16.13.2 - ~/.nvm/versions/node/v16.13.2/bin/node Yarn: 1.22.15 - ~/.nvm/versions/node/v16.13.2/bin/yarn npm: 8.1.2 - ~/.nvm/versions/node/v16.13.2/bin/npm pnpm: 6.11.0 - ~/.nvm/versions/node/v16.13.2/bin/pnpm npmPackages: @aws-amplify/datastore-storage-adapter: ^2.0.42 => 2.0.42 @aws-amplify/ui-react-native: ^1.2.20 => 1.2.20 @azure/core-asynciterator-polyfill: ^1.0.2 => 1.0.2 @babel/core: ^7.20.0 => 7.22.9 @expo/webpack-config: ^19.0.0 => 19.0.0 @react-native-async-storage/async-storage: 1.18.2 => 1.18.2 @react-native-community/netinfo: 9.3.10 => 9.3.10 @react-native/gradle-plugin: ^0.72.11 => 0.72.11 @react-navigation/bottom-tabs: ^6.5.8 => 6.5.8 @react-navigation/material-bottom-tabs: ^6.2.16 => 6.2.16 @react-navigation/native-stack: ^6.9.13 => 6.9.13 @reduxjs/toolkit: ^1.9.5 => 1.9.5 @reduxjs/toolkit-query: 1.0.0 @reduxjs/toolkit-query-react: 1.0.0 @testing-library/jest-native: ^5.4.2 => 5.4.2 @testing-library/react-native: ^12.1.3 => 12.1.3 @types/jest: ^29.5.3 => 29.5.3 @types/react: ~18.2.14 => 18.2.21 HelloWorld: 0.0.1 amazon-cognito-identity-js: ^6.3.1 => 6.3.6 amazon-cognito-identity-js/internals: undefined () aws-amplify: ^5.3.11 => 5.3.11 core-js: ^3.31.1 => 3.31.1 experiments-app: 1.0.0 expo: ^49.0.11 => 49.0.11 expo-file-system: ~15.4.4 => 15.4.4 expo-splash-screen: ~0.20.5 => 0.20.5 expo-sqlite: ~11.3.3 => 11.3.3 expo-status-bar: ~1.6.0 => 1.6.0 faker: 5.5.3 => 5.5.3 jest-expo: ^49.0.0 => 49.0.0 mock-async-storage: ^2.2.0 => 2.2.0 moment: ^2.29.4 => 2.29.4 moment-timezone: ^0.5.43 => 0.5.43 react: 18.2.0 => 18.2.0 react-content-loader: ^6.2.1 => 6.2.1 react-content-loader/native: undefined () react-native: 0.72.5 => 0.72.5 react-native-get-random-values: ~1.9.0 => 1.9.0 react-native-paper: ^5.9.1 => 5.9.1 react-native-paper-dates: ^0.18.12 => 0.18.12 react-native-safe-area-context: 4.6.3 => 4.6.3 react-native-screens: ~3.22.0 => 3.22.1 react-native-svg: 13.9.0 => 13.9.0 react-native-testing-library-website: 0.0.0 react-native-url-polyfill: ^2.0.0 => 2.0.0 (1.3.0) react-native-web: ~0.19.6 => 0.19.8 react-navigation-example: 0.0.1 react-redux: ^8.1.1 => 8.1.1 redux-example: 0.0.1 redux-saga: ^1.2.3 => 1.2.3 redux-saga/effects: undefined () typescript: ^5.1.3 => 5.2.2 npmGlobalPackages: @aws-amplify/cli: 12.2.3 @bubblewrap/cli: 1.18.1 @ionic/cli: 6.20.3 amplify-cli: 1.0.0 aws-cdk: 2.100.0 cordova: 11.1.0 corepack: 0.10.0 create-react-native-app: 3.9.0 deadfile: 2.0.1 docsify-cli: 4.4.4 eas-cli: 5.2.0 graphql-language-service-cli: 3.3.16 jsonminify: 0.4.2 mjson: 0.4.2 native-run: 1.7.1 npm: 8.1.2 prebuild-install: 7.1.1 react_app: 0.1.0 react-dom: 17.0.2 react-js-to-ts: 1.4.0 react: 18.2.0 serve: 14.2.0 sharp-cli: 4.1.1 ts-node: 10.9.1 typescript: 4.8.2 uglify-js: 3.17.4 uglifyjs: 2.4.11 ```Describe the bug
We have been using an app developed with both React and React Native. It uses DataStore to do the majority of communications with the API.
I've had several reports from users along these lines:
Incoming data does not sync, or takes a very long time (sometimes hours) to sync and appear on their devices.
Outgoing data does not sync, until the user logs out and logs back in again. Logging out clears the datastore. This means that their unsynced data is lost and they have to re-enter it. Then sync starts to work again.
There are the two most relevant models in my schema:
type Task
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update]},
])
@model {
id: ID!
tenantId: ID! @index(name: "byTenantId", queryField: "listTasksByTenantId", sortKeyFields: ["createdAt"])
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read]},
])
createdAt: String @auth(rules: [{allow: private, operations: [read]}])
createdBy: User @belongsTo
dateCreated: AWSDate!
timeOfCall: AWSDateTime
timePickedUp: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timePickedUpSenderName: String
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeDroppedOff: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeDroppedOffRecipientName: String
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeCancelled: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeRejected: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeRiderHome: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
requesterContact: AddressAndContactDetails
pickUpLocationId: ID @index(name: "byPickUpLocation")
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
dropOffLocationId: ID @index(name: "byDropOffLocation")
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
establishmentLocationId: ID @index(name: "byEstasblishmentLocation")
pickUpLocation: Location @belongsTo(fields: ["pickUpLocationId"])
dropOffLocation: Location @belongsTo(fields: ["dropOffLocationId"])
establishmentLocation: Location @belongsTo(fields: ["establishmentLocationId"])
riderResponsibility: String
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
assignees: [TaskAssignee] @hasMany
priority: Priority
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
deliverables: [Deliverable] @hasMany
comments: [Comment] @hasMany(indexName: "byParent", fields: ["id"])
status: TaskStatus @index(name: "byStatus", queryField: "tasksByStatus")
isRiderUsingOwnVehicle: Int @default(value: "0")
archived: Int @default(value: "0") @index(name: "byArchivedStatus", queryField: "tasksByArchivedStatus", sortKeyFields: ["status"])
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read]},
])
}
type TaskAssignee
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, delete]},
])
@model {
id: ID!
tenantId: ID! @index(name: "byTenantId")
role: Role!
task: Task! @belongsTo
assignee: User! @belongsTo
archived: Int @default(value: "0") @index(name: "byArchived")
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, delete]},
])
}This is the entire schema: https://github.com/platelet-app/platelet/blob/master/amplify/backend/api/platelet/schema.graphql
The TaskAssignee record is created to assign a user to a Task. This is the model that the dashboard subscribes to with DataStore.observeQuery to show their tasks to the user, and could be why it takes a long time for incoming data to appear.
The Task record is what gets updated when the user updates their tasks through the dashboard. It is linked to the TaskAssignees model.
In the past I've experienced an issue with DataStore.observeQuery not working properly when {allow: private, operations: [read]}, is used instead of {allow: groups, groups: ["USER"], operations: [read]},. I would find that data did not appear if it was created by another user when the model also had owner auth. I'm not sure that this is related or not, but I could try using the groups auth instead of private auth on my other models. It'll just need to be tested before going into production, where the issues are happening.
There are two versions of Amplify being used, on the web version it uses ^4.3.46 and on the mobile ^5.3.11. I can't update the web version yet because of breaking changes. All records are created on the web version. The mobile version only reads and updates data. However I get similar reports from users using the web version for the same purposes as the mobile version (for updating Tasks while out and about).
I have x-ray set up and have looked through logs during times I know users have had issues, but nothing stands out. Every log entry says "OK". If I know what I need to be looking for I might be able to find some more useful information.
Expected behavior
I expect DataStore to always sync incoming and outgoing data.
Reproduction steps
It's difficult to specify steps for a basic reproduction. If it's helpful to, the project can be built and deployed from https://github.com/platelet-app/platelet and the mobile version is here: https://github.com/platelet-app/platelet-mobile
More or less it is an Amplify project with DataStore enabled and optimistic concurrency, using cognito for auth with user groups.
Code Snippet
I use this hook to synchronise data on my dashboard. It can be explored better here https://github.com/platelet-app/platelet/blob/master/src/hooks/useTasksColumnTasks.ts
import React from "react";
import * as models from "../models";
import { useSelector } from "react-redux";
import {
getRoleView,
getWhoami,
taskAssigneesSelector,
dashboardFilteredUserSelector,
taskAssigneesReadyStatusSelector,
dataStoreModelSyncedStatusSelector,
selectionActionsPendingSelector,
} from "../redux/Selectors";
import getAllMyTasks from "./utilities/getAllMyTasks";
import getAllTasksByUser from "./utilities/getAllTasksByUser";
import getTasksAll from "./utilities/getTasksAll";
import { DataStore } from "aws-amplify";
import _ from "lodash";
export type TaskStateType = {
[key: string]: models.Task;
};
export function convertTasksToStateType(tasks: models.Task[]): TaskStateType {
const state: TaskStateType = {};
tasks.forEach((task) => {
state[task.id] = task;
});
return state;
}
const useTasksColumnTasks = (taskStatusKey: models.TaskStatus[]) => {
const [state, setState] = React.useState<TaskStateType>({});
const dashboardFilteredUser = useSelector(dashboardFilteredUserSelector);
const roleView = useSelector(getRoleView);
const dataStoreModelSynced = useSelector(
dataStoreModelSyncedStatusSelector
).Task;
const selectionActionsPending = useSelector(
selectionActionsPendingSelector
);
const tasksSubscription = React.useRef({
unsubscribe: () => {},
});
const stateRef = React.useRef<TaskStateType>({});
const locationsSubscription = React.useRef({
unsubscribe: () => {},
});
const whoami = useSelector(getWhoami);
const taskAssignees = useSelector(taskAssigneesSelector);
const taskAssigneesReady = useSelector(taskAssigneesReadyStatusSelector);
const [isFetching, setIsFetching] = React.useState(true);
const [error, setError] = React.useState(false);
stateRef.current = state;
const tasksKeyJSON = JSON.stringify(taskStatusKey);
let myTaskAssigneeIds = taskAssignees.items
.filter(
(a: models.TaskAssignee) =>
a?.assignee?.id === whoami?.id && a?.role === roleView
)
.map((a2: models.TaskAssignee) => a2?.task?.id);
function addTaskToState(newTask: models.Task) {
setState((prevState) => {
return { ...prevState, [newTask.id]: newTask };
});
}
function removeTaskFromState(newTask: models.Task) {
setState((prevState) => {
if (prevState[newTask.id]) return _.omit(prevState, newTask.id);
else return prevState;
});
}
if (dashboardFilteredUser && roleView === models.Role.COORDINATOR) {
const theirAssignments = taskAssignees.items.filter(
(a: models.TaskAssignee) =>
a.role === models.Role.RIDER &&
a.task &&
a.assignee?.id === dashboardFilteredUser
);
const theirTaskIds = theirAssignments.map(
(a: models.TaskAssignee) => a.task?.id
);
const intersectingTasksIds = _.intersection(
myTaskAssigneeIds,
theirTaskIds
);
myTaskAssigneeIds = intersectingTasksIds;
}
const sortedMyTaskAssigneeIds = myTaskAssigneeIds.sort(
(a: string, b: string) => {
return a.localeCompare(b);
}
);
const taskIdsJson = JSON.stringify(sortedMyTaskAssigneeIds);
const getTasks = React.useCallback(async () => {
if (
!roleView ||
!taskAssigneesReady ||
selectionActionsPending ||
!taskStatusKey
) {
return;
} else {
try {
if (
taskStatusKey.includes(models.TaskStatus.PENDING) ||
(roleView === "ALL" && !dashboardFilteredUser)
) {
setState(await getTasksAll(taskStatusKey));
} else if (roleView === "ALL" && dashboardFilteredUser) {
setState(
await getAllTasksByUser(
taskStatusKey,
dashboardFilteredUser,
models.Role.RIDER,
taskAssignees.items
)
);
} else if (roleView !== "ALL") {
setState(
await getAllMyTasks(taskStatusKey, myTaskAssigneeIds)
);
}
setIsFetching(false);
} catch (error) {
setError(true);
setIsFetching(false);
console.log(error);
}
}
}, [
dashboardFilteredUser,
tasksKeyJSON,
roleView,
selectionActionsPending,
taskIdsJson,
taskAssigneesReady,
whoami.id,
]);
React.useEffect(() => {
getTasks();
}, [getTasks, dataStoreModelSynced]);
const selectionActionsPendingRef = React.useRef(false);
selectionActionsPendingRef.current = selectionActionsPending;
const setUpObservers = React.useCallback(
(roleView, taskKey) => {
tasksSubscription.current.unsubscribe();
tasksSubscription.current = DataStore.observe(
models.Task
).subscribe((newTask) => {
if (selectionActionsPendingRef.current) return;
try {
if (newTask.opType === "UPDATE") {
if (
newTask.element.status &&
taskKey.includes(newTask.element.status) &&
!(newTask.element.id in stateRef.current)
) {
getTasks();
return;
} else if (
newTask.element.status &&
!taskKey.includes(newTask.element.status)
) {
removeTaskFromState(newTask.element);
return;
} else if (newTask.element.id in stateRef.current) {
addTaskToState(newTask.element);
}
} else {
// if roleView is rider or coordinator, let the assignments observer deal with it
if (
roleView !== "ALL" &&
!taskKey.includes(models.TaskStatus.PENDING)
)
return;
if (taskKey.includes(newTask.element.status)) {
getTasks();
}
}
} catch (error) {
console.log(error);
}
});
locationsSubscription.current.unsubscribe();
locationsSubscription.current = DataStore.observe(
models.Location
).subscribe(async (location) => {
try {
if (location.opType === "UPDATE") {
for (const task of Object.values(stateRef.current)) {
if (
task.pickUpLocation &&
task.pickUpLocation.id === location.element.id
) {
setState((prevState) => ({
...prevState,
[task.id]: {
...prevState[task.id],
pickUpLocation: location.element,
},
}));
}
if (
task.dropOffLocation &&
task.dropOffLocation.id === location.element.id
) {
setState((prevState) => ({
...prevState,
[task.id]: {
...prevState[task.id],
dropOffLocation: location.element,
},
}));
}
}
}
} catch (error) {
console.log(error);
}
});
},
[getTasks]
);
React.useEffect(() => {
setUpObservers(roleView, taskStatusKey);
}, [roleView, setUpObservers, taskStatusKey]);
React.useEffect(() => {
return () => {
tasksSubscription.current.unsubscribe();
locationsSubscription.current.unsubscribe();
};
}, []);
return { state, isFetching, error };
};
export default useTasksColumnTasks;The same hook for populating the dashboard on the mobile version. This one is a bit simpler and cleaner than the web one.
import * as React from "react";
import * as models from "../models";
import { useSelector } from "react-redux";
import { getWhoami } from "../redux/Selectors";
import { DataStore } from "aws-amplify";
import convertModelListToTypedObject from "./utilities/convertModelListToTypedObject";
import _ from "lodash";
import useAppActiveStatus from "./useAppActiveStatus";
export type ResolvedTask = Omit<
models.Task,
"pickUpLocation" | "dropOffLocation"
> & {
pickUpLocation: models.Location | null;
dropOffLocation: models.Location | null;
};
type StateType = {
[id: string]: ResolvedTask;
};
const log = (message: any) => {
console.log(`[useMyAssignedTasks] ${message}`);
};
const useMyAssignedTasks = (
status: models.TaskStatus[] | models.TaskStatus,
role: models.Role.COORDINATOR | models.Role.RIDER,
limit: boolean = false
) => {
const whoami = useSelector(getWhoami);
const assigneeObserver = React.useRef({ unsubscribe: () => {} });
const tasksObserver = React.useRef({ unsubscribe: () => {} });
const locationObserver = React.useRef({ unsubscribe: () => {} });
const stateRef = React.useRef<StateType>({});
const taskIdsRef = React.useRef<string[] | null>(null);
const [taskIds, setTaskIds] = React.useState<string[] | null>(null);
const [state, setState] = React.useState<StateType>({});
const [error, setError] = React.useState<Error | null>(null);
const [isFetching, setIsFetching] = React.useState(true);
const isFetchingAssigneesRef = React.useRef(true);
const appStatus = useAppActiveStatus();
stateRef.current = state;
taskIdsRef.current = taskIds;
let actualStatus: models.TaskStatus[] = React.useMemo(() => {
if (!Array.isArray(status)) {
return [status];
} else {
return status;
}
}, [status]);
const setUpTasksObserver = React.useCallback(() => {
// when appStatus changes back to the foreground, we want to restart the observer
if (appStatus !== "active") {
tasksObserver.current.unsubscribe();
return;
}
if (isFetchingAssigneesRef.current) {
return;
}
log("setting up tasks observer");
const oneWeekAgo = new Date();
oneWeekAgo.setHours(0, 0, 0, 0);
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const oneWeekAgoString = oneWeekAgo.toISOString();
try {
tasksObserver.current.unsubscribe();
if (limit) {
tasksObserver.current = DataStore.observeQuery(
models.Task,
(t) =>
t.and((t) => [
t.or((t) =>
actualStatus.map((s) => t.status.eq(s))
),
t.or((t) => [
t.createdAt.eq(undefined),
t.createdAt.gt(oneWeekAgoString),
]),
]),
{ sort: (s) => s.createdAt("DESCENDING") }
).subscribe(async ({ items }) => {
const filtered = items.filter((t) =>
taskIdsRef.current?.includes(t.id)
);
const resolvedTasks: ResolvedTask[] = await Promise.all(
filtered.map(async (t) => {
const pickUpLocation =
(await t.pickUpLocation) || null;
const dropOffLocation =
(await t.dropOffLocation) || null;
return {
...t,
pickUpLocation,
dropOffLocation,
};
})
);
setState(
convertModelListToTypedObject<ResolvedTask>(
resolvedTasks
)
);
setIsFetching(false);
});
} else {
tasksObserver.current = DataStore.observeQuery(
models.Task,
(t) =>
t.and((t) => [
t.or((t) =>
actualStatus.map((s) => t.status.eq(s))
),
]),
{ sort: (s) => s.createdAt("DESCENDING") }
).subscribe(async ({ items }) => {
const filtered = items.filter((t) =>
taskIdsRef.current?.includes(t.id)
);
const resolvedTasks: ResolvedTask[] = await Promise.all(
filtered.map(async (t) => {
const pickUpLocation =
(await t.pickUpLocation) || null;
const dropOffLocation =
(await t.dropOffLocation) || null;
return {
...t,
pickUpLocation,
dropOffLocation,
};
})
);
setState(
convertModelListToTypedObject<ResolvedTask>(
resolvedTasks
)
);
setIsFetching(false);
});
}
} catch (error: unknown) {
if (error instanceof Error) {
setError(error);
log(error);
}
setIsFetching(false);
}
}, [taskIds, actualStatus, limit, appStatus]);
React.useEffect(() => {
setUpTasksObserver();
return () => {
tasksObserver.current.unsubscribe();
};
}, [setUpTasksObserver]);
const setUpLocationObserver = React.useCallback(() => {
// when appStatus changes back to the foreground, we want to restart the observer
if (appStatus !== "active") {
locationObserver.current.unsubscribe();
return;
}
log("setting up location observer");
locationObserver.current.unsubscribe();
locationObserver.current = DataStore.observe(models.Location).subscribe(
async (location) => {
try {
if (location.opType === "UPDATE") {
for (const task of Object.values(stateRef.current)) {
if (
task.pickUpLocation &&
task.pickUpLocation.id === location.element.id
) {
setState((prevState) => ({
...prevState,
[task.id]: {
...prevState[task.id],
pickUpLocation: location.element,
},
}));
}
if (
task.dropOffLocation &&
task.dropOffLocation.id === location.element.id
) {
setState((prevState) => ({
...prevState,
[task.id]: {
...prevState[task.id],
dropOffLocation: location.element,
},
}));
}
}
}
} catch (error) {
log(error);
}
}
);
}, [appStatus]);
React.useEffect(() => {
setUpLocationObserver();
return () => {
locationObserver.current.unsubscribe();
};
}, [setUpLocationObserver]);
const setUpAssignedTasksObserver = React.useCallback(async () => {
// when appStatus changes back to the foreground, we want to restart the observer
if (appStatus !== "active") {
assigneeObserver.current.unsubscribe();
return;
}
log("setting up assigned tasks observer");
try {
assigneeObserver.current.unsubscribe();
assigneeObserver.current = DataStore.observeQuery(
models.TaskAssignee,
(a) => a.role.eq(role)
).subscribe(async ({ items }) => {
const resolved = await Promise.all(
items.map(async (a) => {
const assignee = await a.assignee;
const task = await a.task;
return { ...a, assignee, task };
})
);
const filtered = resolved.filter(
(a) => a.assignee.id === whoami?.id
);
const taskIds = filtered.map((t) => t.task.id);
if (_.isEqual(taskIds, taskIdsRef.current)) {
return;
} else {
setTaskIds(taskIds);
}
isFetchingAssigneesRef.current = false;
});
return;
} catch (error: unknown) {
if (error instanceof Error) {
log(error);
setError(error);
}
}
}, [whoami?.id, role, appStatus]);
React.useEffect(() => {
setUpAssignedTasksObserver();
return () => {
assigneeObserver.current.unsubscribe();
};
}, [setUpAssignedTasksObserver]);
return { state: Object.values(state), isFetching, error };
};
export default useMyAssignedTasks;This is the component on mobile for updating tasks. It's very similar on the web version and can be looked at in more detail here: https://github.com/platelet-app/platelet/blob/mobile/mobile/src/screens/Task/components/TaskActions.tsx
import { DataStore } from "aws-amplify";
import * as React from "react";
import { Card, ToggleButton, Text } from "react-native-paper";
import { TouchableOpacity, View } from "react-native";
import useModelSubscription from "../../../hooks/useModelSubscription";
import * as models from "../../../models";
import determineTaskStatus, {
TaskInterface,
} from "../../../utilities/determineTaskStatus";
import TaskTimePicker from "./TaskTimePicker";
import TaskActionsConfirmationDialog from "./TaskActionsConfirmationDialog";
import GenericErrorSnack from "../../../snacks/GenericErrorSnack";
type TaskActionsProps = {
taskId: string;
};
const fields = {
timePickedUp: "Picked up",
timeDroppedOff: "Delivered",
timeCancelled: "Cancelled",
timeRejected: "Rejected",
timeRiderHome: "Rider home",
};
export type TaskUpdateKey = keyof Omit<TaskInterface, "id">;
const TaskActions: React.FC<TaskActionsProps> = ({ taskId }) => {
const [buttonsState, setButtonsState] = React.useState<TaskUpdateKey[]>([]);
const [isPosting, setIsPosting] = React.useState(false);
const [confirmationKey, setConfirmationKey] =
React.useState<TaskUpdateKey | null>(null);
const [editKey, setEditKey] = React.useState<TaskUpdateKey | null>(null);
const { state, setState, isFetching, notFound, error } =
useModelSubscription<models.Task>(models.Task, taskId);
const [snackVisible, setSnackVisible] = React.useState(false);
function onClickToggle(key: TaskUpdateKey) {
setConfirmationKey(key);
}
function onClickEdit(key: TaskUpdateKey) {
setEditKey(key);
}
async function saveValues(values: Partial<TaskInterface>) {
setIsPosting(true);
setConfirmationKey(null);
try {
const existingTask = await DataStore.query(models.Task, taskId);
if (!existingTask) {
throw new Error("Task not found");
}
const status = await determineTaskStatus({
...existingTask,
...values,
});
const updatedTask = await DataStore.save(
models.Task.copyOf(existingTask, (upd) => {
upd.status = status;
for (const key in values) {
upd[key as keyof Omit<TaskInterface, "id">] =
values[key as keyof TaskInterface];
}
})
);
setState(updatedTask);
} catch (e) {
console.log(e);
setSnackVisible(true);
} finally {
setIsPosting(false);
setEditKey(null);
}
}
function calculateState() {
if (!state) return;
const result = Object.keys(fields).filter((key) => {
return !!state[key as keyof typeof fields];
});
setButtonsState(result as TaskUpdateKey[]);
}
React.useEffect(calculateState, [state]);
const getIcon = (key: TaskUpdateKey) => {
if (buttonsState.includes(key)) return "checkbox-marked-outline";
return "checkbox-blank-outline";
};
function checkDisabled(key: TaskUpdateKey) {
if (
notFound ||
error ||
isFetching ||
isPosting ||
state?.status === models.TaskStatus.PENDING
)
return true;
const stopped =
buttonsState.includes("timeCancelled") ||
buttonsState.includes("timeRejected");
if (key === "timeDroppedOff")
return (
buttonsState.includes("timeRiderHome") ||
!buttonsState.includes("timePickedUp") ||
stopped
);
else if (key === "timePickedUp") {
return buttonsState.includes("timeDroppedOff") || stopped;
} else if (key === "timeRiderHome") {
if (state?.status === models.TaskStatus.NEW) return true;
return !buttonsState.includes("timeDroppedOff");
} else if (key === "timeRejected") {
if (buttonsState.includes("timeRejected")) return false;
return (
(buttonsState.includes("timePickedUp") &&
buttonsState.includes("timeDroppedOff")) ||
stopped
);
} else if (key === "timeCancelled") {
if (buttonsState.includes("timeCancelled")) return false;
return (
(buttonsState.includes("timePickedUp") &&
buttonsState.includes("timeDroppedOff")) ||
stopped
);
} else return false;
}
let nameKey:
| "timePickedUpSenderName"
| "timeDroppedOffRecipientName"
| null = null;
if ([confirmationKey, editKey].includes("timePickedUp"))
nameKey = "timePickedUpSenderName";
else if ([confirmationKey, editKey].includes("timeDroppedOff"))
nameKey = "timeDroppedOffRecipientName";
return (
<>
<Card>
<Card.Content>
{Object.entries(fields).map(([key, value], index) => {
let borderTopLeftRadius = 0;
let borderTopRightRadius = 0;
let borderBottomLeftRadius = 0;
let borderBottomRightRadius = 0;
if (index === 0) {
borderTopLeftRadius = 8;
borderTopRightRadius = 8;
}
if (index === Object.entries(fields).length - 1) {
borderBottomLeftRadius = 8;
borderBottomRightRadius = 8;
}
const disabled = checkDisabled(key as TaskUpdateKey);
let showInfo = false;
if (
key === "timePickedUp" &&
state?.timePickedUpSenderName
) {
showInfo = true;
} else if (
key === "timeDroppedOff" &&
state?.timeDroppedOffRecipientName
) {
showInfo = true;
}
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
key={key}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<ToggleButton
size={30}
disabled={checkDisabled(
key as TaskUpdateKey
)}
aria-label={value}
onPress={() => {
onClickToggle(key as TaskUpdateKey);
}}
style={{
height: 53,
width: 53,
borderWidth: 0.4,
borderTopLeftRadius,
borderTopRightRadius,
borderBottomLeftRadius,
borderBottomRightRadius,
}}
icon={getIcon(key as TaskUpdateKey)}
value={key}
status={
buttonsState.includes(
key as TaskUpdateKey
)
? "checked"
: "unchecked"
}
/>
<TouchableOpacity
disabled={disabled}
onPress={() => {
onClickToggle(key as TaskUpdateKey);
}}
>
<Text
style={{
fontSize: 16,
opacity: disabled ? 0.5 : 1,
textTransform: "uppercase",
}}
>
{value}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={() => {
onClickEdit(key as TaskUpdateKey);
}}
>
<TaskTimePicker
time={
state?.[key as keyof TaskInterface]
}
label={`Edit ${value}`}
onClickEdit={() =>
onClickEdit(key as TaskUpdateKey)
}
infoIcon={showInfo}
/>
</TouchableOpacity>
</View>
);
})}
</Card.Content>
</Card>
<TaskActionsConfirmationDialog
key={confirmationKey || "confirmationDialog"}
nullify={!!state?.[confirmationKey as keyof TaskInterface]}
taskKey={confirmationKey as TaskUpdateKey}
nameKey={nameKey}
open={!!confirmationKey}
onClose={() => setConfirmationKey(null)}
onConfirm={saveValues}
/>
<TaskActionsConfirmationDialog
key={editKey || "editDialog"}
startingValue={state?.[editKey as keyof TaskInterface]}
nullify={false}
startingNameValue={state?.[nameKey as keyof TaskInterface]}
taskKey={editKey as TaskUpdateKey}
nameKey={nameKey}
open={!!editKey}
onClose={() => setEditKey(null)}
onConfirm={saveValues}
/>
<GenericErrorSnack
visible={snackVisible}
onDismiss={() => setSnackVisible(false)}
/>
</>
);
};
export default TaskActions;And this is my conflict handler, which I don't know if could be a cause for issues:
import { DISCARD } from "@aws-amplify/datastore";
import {
SyncConflict,
PersistentModel,
PersistentModelConstructor,
} from "@aws-amplify/datastore";
import * as models from "../../models";
import determineTaskStatus from "../../utilities/determineTaskStatus";
const dataStoreConflictHandler = async (
conflict: SyncConflict
): Promise<symbol | PersistentModel> => {
const { modelConstructor, localModel, remoteModel } = conflict;
console.log(
"DataStore has found a conflict",
modelConstructor,
remoteModel,
localModel
);
if (remoteModel.archived === 1) {
return DISCARD;
}
if (
modelConstructor ===
(models.Task as PersistentModelConstructor<models.Task>)
) {
const remote = remoteModel as models.Task;
const local = localModel as models.Task;
let newModel = models.Task.copyOf(remote, (task) => {
task.timePickedUp = remote.timePickedUp || local.timePickedUp;
task.timeDroppedOff = remote.timeDroppedOff || local.timeDroppedOff;
task.timeRiderHome = remote.timeRiderHome || local.timeRiderHome;
task.timeCancelled = remote.timeCancelled || local.timeCancelled;
task.timeRejected = remote.timeRejected || local.timeRejected;
task.timePickedUpSenderName =
remote.timePickedUpSenderName || local.timePickedUpSenderName;
task.timeDroppedOffRecipientName =
remote.timeDroppedOffRecipientName ||
local.timeDroppedOffRecipientName;
});
console.log("Resolved task conflict result:", newModel);
const status = await determineTaskStatus(newModel);
console.log("Updating task status to", status);
newModel = models.Task.copyOf(newModel, (task) => {
task.status = status;
});
const { createdAt, updatedAt, tenantId, archived, ...rest } = newModel;
return rest;
} else if (
modelConstructor ===
(models.Comment as PersistentModelConstructor<models.Comment>)
) {
const { createdAt, updatedAt, tenantId, archived, ...rest } =
remoteModel;
return rest;
}
return DISCARD;
};
export default dataStoreConflictHandler;Log output
Details
// Put your logs below this line
aws-exports.js
This is just an example from my dev aws-exports.
const awsmobile = {
"aws_project_region": "eu-west-1",
"aws_appsync_graphqlEndpoint": "https://zevrqqcatbadzlgyzfiwalvac4.appsync-api.eu-west-1.amazonaws.com/graphql",
"aws_appsync_region": "eu-west-1",
"aws_appsync_authenticationType": "AMAZON_COGNITO_USER_POOLS",
"aws_cognito_identity_pool_id": "eu-west-1:be212540-2dd5-4cc4-a3f4-c067a7b11e5c",
"aws_cognito_region": "eu-west-1",
"aws_user_pools_id": "eu-west-1_02UkIHRM9",
"aws_user_pools_web_client_id": "74qtr5truikqtdd5gavut5r4o9",
"oauth": {},
"aws_cognito_username_attributes": [],
"aws_cognito_social_providers": [],
"aws_cognito_signup_attributes": [
"EMAIL"
],
"aws_cognito_mfa_configuration": "OFF",
"aws_cognito_mfa_types": [
"SMS"
],
"aws_cognito_password_protection_settings": {
"passwordPolicyMinLength": 8,
"passwordPolicyCharacters": []
},
"aws_cognito_verification_mechanisms": [
"EMAIL"
],
"aws_user_files_s3_bucket": "platelet26fb7449fb884a3eb4c5fd7539c78dd3211255-whoa",
"aws_user_files_s3_bucket_region": "eu-west-1",
"geo": {
"amazon_location_service": {
"region": "eu-west-1",
"search_indices": {
"items": [
"plateletPlace-whoa"
],
"default": "plateletPlace-whoa"
}
}
}
};
export default awsmobile;Manual configuration
No response
Additional configuration
No response
Mobile Device
No response
Mobile Operating System
Android, iOS of various versions
Mobile Browser
No response
Mobile Browser Version
No response
Additional information and screenshots
No response