diff --git a/package.json b/package.json index f55a056..3e0d90d 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,7 @@ "react-error-boundary": "^3.0.2", "react-hook-form": "^6.10.1", "react-icons": "^4.1.0", - "react-query": "^2.26.2", - "react-query-devtools": "^2.6.3", + "react-query": "^3.2.0", "react-router": "^6.0.0-beta.0", "react-router-dom": "^6.0.0-beta.0", "react-scripts": "4.0.0", diff --git a/src/api/checkmarks.js b/src/api/checkmarks.js index 5753d6d..59011d0 100644 --- a/src/api/checkmarks.js +++ b/src/api/checkmarks.js @@ -1,4 +1,4 @@ -import { useMutation, useQueryCache, useQuery } from 'react-query'; +import { useMutation, useQueryClient, useQuery } from 'react-query'; import { useFirebase } from 'context/firebase-context'; import { useAuth } from 'context/auth-context'; import { EMPTY } from 'data/constants'; @@ -6,12 +6,12 @@ import { EMPTY } from 'data/constants'; /** * Returns a function that adds a checkmark */ -export function useAddCheckmark() { +export function useAddCheckmarkMutation() { const { db } = useFirebase(); const { user } = useAuth(); - const cache = useQueryCache(); + const queryClient = useQueryClient(); - return useMutation( + const addCheckmarkMutation = useMutation( (checkmark) => { // Get checkmark ref in the database const newCheckmarkRef = db.ref(`checkmarks/${user.uid}`).push(); @@ -27,25 +27,27 @@ export function useAddCheckmark() { // When mutate is called: onMutate: (checkmark) => { // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - cache.cancelQueries('checkmarks'); + queryClient.cancelQueries('checkmarks'); // Snapshot previous values - const previousCheckmarks = cache.getQueryData('checkmarks'); + const previousCheckmarks = queryClient.getQueryData('checkmarks'); // Optimistically add new checkmark - cache.setQueryData('checkmarks', (old) => [...old, checkmark]); + queryClient.setQueryData('checkmarks', (old) => [...old, checkmark]); // Return a context object with the snapshotted value return { previousCheckmarks }; }, // If the mutation fails, use the context returned from onMutate to roll back onError: (error, newCheckmark, context) => { - cache.setQueryData('checkmarks', context.previousCheckmarks); + queryClient.setQueryData('checkmarks', context.previousCheckmarks); }, // Always refetch after error or success: - onSettled: () => cache.invalidateQueries('checkmarks'), + onSettled: () => queryClient.invalidateQueries('checkmarks'), } ); + + return addCheckmarkMutation; } /** @@ -55,7 +57,10 @@ export function useFetchCheckmarks() { const { db } = useFirebase(); const { user } = useAuth(); - return () => { + /** + * Fetches checkmarks all the user's checkmarks from the database. + */ + const fetchCheckmarks = () => { // Get all the user's checkmarks from the database return db .ref(`checkmarks/${user.uid}`) @@ -76,6 +81,8 @@ export function useFetchCheckmarks() { return checkmarks; }); }; + + return fetchCheckmarks; } /** @@ -83,13 +90,15 @@ export function useFetchCheckmarks() { * * Returns a react-query query, that fetches the checkmarks using `fetchCheckmark` */ -export function useCheckmarks() { +export function useCheckmarksQuery() { const fetchCheckmarks = useFetchCheckmarks(); - return useQuery('checkmarks', fetchCheckmarks, { + const checkmarksQuery = useQuery('checkmarks', fetchCheckmarks, { initialData: [], initialStale: true, }); + + return checkmarksQuery; } /** @@ -99,12 +108,12 @@ export function useCheckmarks() { * The mutation has to be called with checkmark id. * */ -export function useDeleteCheckmark() { +export function useDeleteCheckmarkMutation() { const { db } = useFirebase(); const { user } = useAuth(); - const cache = useQueryCache(); + const queryClient = useQueryClient(); - return useMutation( + const deleteCheckmarkMutation = useMutation( (checkmarkId) => { const checkmarkRef = db.ref(`checkmarks/${user.uid}/${checkmarkId}`); // Remove the checkmark in the database @@ -114,13 +123,13 @@ export function useDeleteCheckmark() { // When mutate is called: onMutate: (checkmarkId) => { // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - cache.cancelQueries('checkmarks'); + queryClient.cancelQueries('checkmarks'); // Snapshot previous values - const previousCheckmarks = cache.getQueryData('checkmarks'); + const previousCheckmarks = queryClient.getQueryData('checkmarks'); - // Optimistically remove the checkmark from cache - cache.setQueryData('checkmarks', (old) => + // Optimistically remove the checkmark from queryClient + queryClient.setQueryData('checkmarks', (old) => old.filter((checkmark) => checkmark.id !== checkmarkId) ); @@ -129,12 +138,14 @@ export function useDeleteCheckmark() { }, // If the mutation fails, use the context returned from onMutate to roll back onError: (error, checkmarkId, context) => { - cache.setQueryData('checkmarks', context.previousCheckmarks); + queryClient.setQueryData('checkmarks', context.previousCheckmarks); }, // Always refetch after error or success: - onSettled: () => cache.invalidateQueries('checkmarks'), + onSettled: () => queryClient.invalidateQueries('checkmarks'), } ); + + return deleteCheckmarkMutation; } /** @@ -143,12 +154,12 @@ export function useDeleteCheckmark() { * Returns a react-query mutation, that updates the checkmark value. * The mutation has to be called with checkmark object. */ -export function useUpdateCheckmarkValue() { +export function useUpdateCheckmarkMutation() { const { db } = useFirebase(); const { user } = useAuth(); - const cache = useQueryCache(); + const queryClient = useQueryClient(); - return useMutation( + const updateCheckmarkMutation = useMutation( (checkmark) => { // Get checkmark database ref const checkmarkRef = db.ref(`checkmarks/${user.uid}/${checkmark.id}`); @@ -164,13 +175,13 @@ export function useUpdateCheckmarkValue() { // When mutate is called: onMutate: (newCheckmark) => { // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - cache.cancelQueries('checkmarks'); + queryClient.cancelQueries('checkmarks'); // Snapshot previous values - const previousCheckmarks = cache.getQueryData('checkmarks'); + const previousCheckmarks = queryClient.getQueryData('checkmarks'); // Optimistically update to the new checkmark value - cache.setQueryData('checkmarks', (old) => + queryClient.setQueryData('checkmarks', (old) => old.map((checkmark) => { if (checkmark.id === newCheckmark.id) { return { @@ -188,20 +199,27 @@ export function useUpdateCheckmarkValue() { }, // If the mutation fails, use the context returned from onMutate to roll back onError: (error, newCheckmark, context) => { - cache.setQueryData('checkmarks', context.previousCheckmarks); + queryClient.setQueryData('checkmarks', context.previousCheckmarks); }, // Always refetch after error or success: - onSettled: () => cache.invalidateQueries('checkmarks'), + onSettled: () => queryClient.invalidateQueries('checkmarks'), } ); -} -export function useUpdateCheckmarkInDb() { - const [addCheckmark] = useAddCheckmark(); - const [updateCheckmarkValue] = useUpdateCheckmarkValue(); - const [deleteCheckmarkById] = useDeleteCheckmark(); + return updateCheckmarkMutation; +} - return ({ checkmarkId, habitId, value, date }) => { +export function useUpdateCheckmarkInDbMutate() { + const addCheckmark = useAddCheckmarkMutation(); + const updateCheckmark = useUpdateCheckmarkMutation(); + const deleteCheckmark = useDeleteCheckmarkMutation(); + + const updateCheckmarkInDbMutation = ({ + checkmarkId, + habitId, + value, + date, + }) => { // Checkmark id is falsy so the checkmark doesn't exists. // A new checkmark should be added. const shouldAdd = !checkmarkId; @@ -216,15 +234,21 @@ export function useUpdateCheckmarkInDb() { // Update if (shouldUpdate) { - return updateCheckmarkValue({ id: checkmarkId, value }); + return updateCheckmark.mutate({ id: checkmarkId, value }); // Delete } else if (shouldDelete) { - return deleteCheckmarkById(checkmarkId); + return deleteCheckmark.mutate(checkmarkId); // Add } else if (shouldAdd) { - return addCheckmark({ habitId, date, value }); + return addCheckmark.mutate({ habitId, date, value }); + } else { + throw new Error( + 'Unhandled case when updating the checkmark in the database.' + ); } }; + + return updateCheckmarkInDbMutation; } diff --git a/src/api/habits.js b/src/api/habits.js index 9804191..496b31d 100644 --- a/src/api/habits.js +++ b/src/api/habits.js @@ -1,19 +1,19 @@ import { useAuth } from 'context/auth-context'; import { useFirebase } from 'context/firebase-context'; -import { useQueryCache, useQuery, useMutation } from 'react-query'; +import { useQueryClient, useQuery, useMutation } from 'react-query'; /** - * Use add habit hook - * + * Use add habit mutation + * * @returns a react-query mutation that adds a new habit to the database. * The mutation takes as an argument a new habit object. */ -export function useAddHabit() { +export function useAddHabitMutation() { const { db } = useFirebase(); const { user } = useAuth(); - const cache = useQueryCache(); + const queryClient = useQueryClient(); - return useMutation( + const addHabitMutation = useMutation( (habit) => { const { name, description, frequency, position } = habit; @@ -30,25 +30,27 @@ export function useAddHabit() { }); }, { - onSuccess: () => cache.invalidateQueries('habits'), + onSuccess: () => queryClient.invalidateQueries('habits'), } ); + + return addHabitMutation; } /** * Use delete habit hook - * + * * @returns a react-query mutation that deletes the habit and all the checkmarks * associated with it from the database. - * + * * The mutation takes as an argument habit id. */ -export function useDeleteHabit() { +export function useDeleteHabitMutationMutation() { const { db } = useFirebase(); const { user } = useAuth(); - const cache = useQueryCache(); + const queryClient = useQueryClient(); - return useMutation( + const deleteHabitMutation = useMutation( async (habitId) => { // When deleting the habit we have to delete both the habit // and all the habit's checkmarks @@ -82,20 +84,20 @@ export function useDeleteHabit() { // When mutate is called: onMutate: async (habitId) => { // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - await cache.cancelQueries('habits'); - await cache.cancelQueries('checkmarks'); + await queryClient.cancelQueries('habits'); + await queryClient.cancelQueries('checkmarks'); // Snapshot previous values - const previousHabits = cache.getQueryData('habits'); - const previousCheckmarks = cache.getQueryData('checkmarks'); + const previousHabits = queryClient.getQueryData('habits'); + const previousCheckmarks = queryClient.getQueryData('checkmarks'); - // Optimistically remove the habit from cache - cache.setQueryData('habits', (old) => + // Optimistically remove the habit from queryClient + queryClient.setQueryData('habits', (old) => old.filter((habit) => habit.id !== habitId) ); - // Optimistically remove the habit's checkmarks from cache - cache.setQueryData('checkmarks', (old) => + // Optimistically remove the habit's checkmarks from queryClient + queryClient.setQueryData('checkmarks', (old) => old.filter((checkmark) => checkmark.habitId !== habitId) ); @@ -104,28 +106,35 @@ export function useDeleteHabit() { }, // If the mutation fails, use the context returned from onMutate to roll back onError: (error, habitId, context) => { - cache.setQueryData('habits', context.previousHabits); - cache.setQueryData('checkmarks', context.previousCheckmarks); + queryClient.setQueryData('habits', context.previousHabits); + queryClient.setQueryData('checkmarks', context.previousCheckmarks); }, // Always refetch after error or success: - onSettled: () => { - cache.invalidateQueries('habits'); - cache.invalidateQueries('checkmarks'); - } + onSettled: () => { + queryClient.invalidateQueries('habits'); + queryClient.invalidateQueries('checkmarks'); + }, } ); + + return deleteHabitMutation; } /** * Use fetch habit by id hook - * + * * @returns a function that fetches a habit by id from the database. */ -export function useFetchHabitById() { +export function useFetchHabit() { const { db } = useFirebase(); const { user } = useAuth(); - return (key, { id }) => { + /** + * Fetch habit + * + * @param {string} id - ID of the habit to fetch + */ + const fetchHabit = (id) => { // Get habit database ref const habitRef = db.ref(`habits/${user.uid}/${id}`); @@ -144,34 +153,38 @@ export function useFetchHabitById() { } }); }; + + return fetchHabit; } /** * Use habit by id hook - * + * * @param {string} habitId - * - * @returns a react-query query that fetches the habit by id, using `fetchHabitById` + * + * @returns a react-query query that fetches the habit by id, using `fetchHabit` */ -export function useHabitById(habitId) { - const fetchHabitById = useFetchHabitById(); +export function useHabitQuery(id) { + const fetchHabit = useFetchHabit(); - return useQuery(habitId && ['habit', { id: habitId }], fetchHabitById, { - enabled: habitId !== null, + const habitQuery = useQuery(id && ['habit', id], () => fetchHabit(id), { + enabled: id !== null, }); + + return habitQuery; } /** * Use habits hook - * + * * @returns react-query query for that fetches all the user's habits from * the database. */ -export function useHabits() { +export function useHabitsQuery() { const { db } = useFirebase(); const { user } = useAuth(); - return useQuery('habits', () => { + const habitsQuery = useQuery('habits', () => { // Get all the user's habits from the database return db .ref(`habits/${user.uid}`) @@ -192,20 +205,22 @@ export function useHabits() { return fetchedHabits; }); }); + + return habitsQuery; } /** * Use update habit hook - * + * * @returns a react-query mutation that updates the habit in the database. * The mutation takes as an argument habit object. */ -export function useUpdateHabit() { +export function useUpdateHabitMutation() { const { db } = useFirebase(); const { user } = useAuth(); - const cache = useQueryCache(); + const queryClient = useQueryClient(); - return useMutation( + const updateHabitMutation = useMutation( (habit) => { const { id, name, description, frequency } = habit; @@ -227,10 +242,10 @@ export function useUpdateHabit() { { // When mutate is called: onMutate: (habit) => { - const previousHabit = cache.getQueryData(['habit', { id: habit.id }]); + const previousHabit = queryClient.getQueryData(['habit', habit.id]); // Snapshot previous values - cache.setQueryData(['habit', { id: habit.id }], (old) => ({ + queryClient.setQueryData(['habit', habit.id], (old) => ({ ...old, ...habit, })); @@ -240,13 +255,15 @@ export function useUpdateHabit() { }, // If the mutation fails, use the context returned from onMutate to roll back onError: (error, newCheckmark, context) => { - cache.setQueryData('habits', context.previousCheckmarks); + queryClient.setQueryData('habits', context.previousCheckmarks); }, // Always refetch after error or success: onSuccess: async (habit) => { - cache.refetchQueries('habits'); - await cache.refetchQueries(['habit', { id: habit.id }]); + queryClient.refetchQueries('habits'); + await queryClient.refetchQueries(['habit', habit.id]); }, } ); + + return updateHabitMutation; } diff --git a/src/api/user-data.js b/src/api/user-data.js index 51db0dc..4d8e32b 100644 --- a/src/api/user-data.js +++ b/src/api/user-data.js @@ -1,5 +1,6 @@ import { useFirebase } from 'context/firebase-context'; import { useAuth } from 'context/auth-context'; +import { useQueryClient } from 'react-query'; /** * Use update performance goal hook @@ -40,6 +41,7 @@ export function useUpdateLocaleCode() { export function useDeleteUserData() { const { user } = useAuth(); const { db } = useFirebase(); + const queryClient = useQueryClient(); return () => { const updates = {}; @@ -48,6 +50,33 @@ export function useDeleteUserData() { updates[`checkmarks/${user.uid}`] = null; updates[`users/${user.uid}`] = null; + return db.ref().update(updates).then(() => { + queryClient.invalidateQueries(); + }); + }; +} + +/** + * Returns a function that updates the user data in the database. + */ +export function useUpdateUserData() { + const { user } = useAuth(); + const { db } = useFirebase(); + + /** + * Updates the user data in the database. + * + * @param { {checkmarks: string[]} } + */ + const updateUserData = ({ checkmarks, habits, settings }) => { + const updates = {}; + + if (checkmarks) updates[`checkmarks/${user.uid}`] = checkmarks; + if (habits) updates[`habits/${user.uid}`] = habits; + if (settings) updates[`users/${user.uid}`] = settings; + return db.ref().update(updates); }; + + return updateUserData; } diff --git a/src/app/authenticated-app.js b/src/app/authenticated-app.js index 50b3861..66badfa 100644 --- a/src/app/authenticated-app.js +++ b/src/app/authenticated-app.js @@ -8,11 +8,12 @@ import { EditHabitScreen } from 'screens/edit-habit'; import { ManageHabitsScreen } from 'screens/manage-habits'; import { NotFoundScreen } from 'screens/not-found'; import { UserSettingsScreen } from 'screens/user-settings'; -import { useUpdateLocaleCode } from 'api/user-data'; +import { useUpdateLocaleCode, useUpdateUserData } from 'api/user-data'; import { FullPageErrorFallback, ErrorFallback } from 'components/lib'; import { LocaleSelect } from 'components/locale-select'; import { useAuth } from 'context/auth-context'; import { useDialog } from 'context/dialog-context'; +import { useQueryClient } from 'react-query'; import { Divider, List, Toolbar, Typography } from '@material-ui/core'; import { Add as AddIcon, @@ -30,21 +31,72 @@ import { SidebarLink, } from 'layout/authenticated-layout'; import { GithubRepoLink } from 'components/github-repo-link'; +import { useDeleteUserData } from 'api/user-data'; +import * as guestData from 'data/guest'; /** * Authenticated App */ function AuthenticatedApp() { - const { signOut } = useAuth(); + const queryClient = useQueryClient(); + const { user, signOut } = useAuth(); const { openDialog } = useDialog(); const t = useTranslation(); + const deleteUserData = useDeleteUserData(); + const updateUserData = useUpdateUserData(); + + /** + * Initializing the data when user is Anonymous + * + * When user logs in as guest we provide him with some dummy data. This data + * is immediately stored in the cache and updated in user's data point in the database. + * + * This will be handy when creating an account by linking this anonymous account. + */ + React.useEffect(() => { + /** + * Check if user is anonymous and if they have habits and checkmarks in the cache. + * If they've already have the data in cache it means that it has already been initialized + * and there is no need to do it again. + */ + + if (user.isAnonymous) { + const { habits, dbHabits, checkmarks, dbCheckmarks } = guestData; + + // Set data in the cache + queryClient.setQueryData('habits', habits); + queryClient.setQueryData('checkmarks', checkmarks); + + // Set data in the database + updateUserData({ + habits: dbHabits, + checkmarks: dbCheckmarks, + }); + } + // Run ONLY on initial mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Logout click handler const handleLogoutClick = () => { openDialog({ title: t('signOutQuestion'), description: t('signOutDescription'), confirmText: t('signOutConfirm'), - onConfirm: signOut, + onConfirm: async () => { + try { + // When signing out and user is anonymous, delete their data + if (user.isAnonymous) { + await deleteUserData(); + await signOut(); + } else { + await signOut(); + } + } catch (error) { + console.log(error, error.message); + } + }, color: 'secondary', }); }; diff --git a/src/app/unauthenticated-app.js b/src/app/unauthenticated-app.js index e0d1c1e..b6d6ca7 100644 --- a/src/app/unauthenticated-app.js +++ b/src/app/unauthenticated-app.js @@ -16,6 +16,7 @@ import { makeStyles, Toolbar, Typography, + Box, } from '@material-ui/core'; import { FullPageImageBackground, LandingScreen } from 'screens/landing'; import { ResetPasswordScreen } from 'screens/reset-password'; @@ -25,6 +26,7 @@ import { LocaleSelect } from 'components/locale-select'; import { useTranslation } from 'translations'; import { GithubRepoLink } from 'components/github-repo-link'; import { DarkModeSwitch } from 'components/dark-mode-switch'; +import { Copyright } from 'components/copyright'; const useStyles = makeStyles((theme) => ({ // App @@ -68,6 +70,7 @@ function UnathenticatedApp() { +