Skip to content

Commit

Permalink
feat(mobile): update categories to use zustand store (#131)
Browse files Browse the repository at this point in the history
  • Loading branch information
duongdev committed Jul 16, 2024
1 parent 91046ce commit 837979e
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 73 deletions.
39 changes: 13 additions & 26 deletions apps/mobile/app/(app)/category/[categoryId].tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,15 @@
import { CategoryForm } from '@/components/category/category-form'
import { Text } from '@/components/ui/text'
import { updateCategory } from '@/mutations/category'
import { categoryQueries, useCategories } from '@/queries/category'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCategory, useUpdateCategory } from '@/stores/category/hooks'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { Alert, ScrollView, View } from 'react-native'
import { ScrollView, View } from 'react-native'

export default function EditCategoryScreen() {
const { categoryId } = useLocalSearchParams<{ categoryId: string }>()
const { data: categories = [] } = useCategories()
const queryClient = useQueryClient()
const router = useRouter()
const { categoryId } = useLocalSearchParams<{ categoryId: string }>()
const { category } = useCategory(categoryId!)

const { mutateAsync: mutateUpdate } = useMutation({
mutationFn: updateCategory,
onError(error) {
Alert.alert(error.message)
},
onSuccess() {
router.back()
},
async onSettled() {
await queryClient.invalidateQueries({
queryKey: categoryQueries.list._def,
})
},
throwOnError: true,
})

const category = categories.find((category) => category.id === categoryId)
const { mutateAsync: mutateUpdate } = useUpdateCategory()

if (!category) {
return (
Expand All @@ -39,9 +20,15 @@ export default function EditCategoryScreen() {
}

return (
<ScrollView className="bg-card px-6 py-3">
<ScrollView
className="bg-card px-6 py-3"
keyboardShouldPersistTaps="handled"
>
<CategoryForm
onSubmit={(values) => mutateUpdate({ id: category.id, data: values })}
onSubmit={async (values) => {
mutateUpdate({ id: category.id, data: values })
router.back()
}}
hiddenFields={['type']}
defaultValues={{
name: category?.name,
Expand Down
16 changes: 5 additions & 11 deletions apps/mobile/app/(app)/category/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CategoryItem } from '@/components/category/category-item'
import { AddNewButton } from '@/components/common/add-new-button'
import { Skeleton } from '@/components/ui/skeleton'
import { Text } from '@/components/ui/text'
import { useCategories } from '@/queries/category'
import { useCategoryList } from '@/stores/category/hooks'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { useRouter } from 'expo-router'
Expand All @@ -12,16 +12,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
export default function CategoriesScreen() {
const { i18n } = useLingui()
const router = useRouter()
const { data: categories = [], isLoading, refetch } = useCategories()
const { incomeCategories, expenseCategories, isRefetching, refetch } =
useCategoryList()
const { bottom } = useSafeAreaInsets()

const incomeCategories = categories.filter(
(category) => category.type === 'INCOME',
)
const expenseCategories = categories.filter(
(category) => category.type === 'EXPENSE',
)

const sections = [
{ key: 'INCOME', title: 'Incomes', data: incomeCategories },
{ key: 'EXPENSE', title: 'Expenses', data: expenseCategories },
Expand All @@ -31,7 +25,7 @@ export default function CategoriesScreen() {
<SectionList
className="bg-card flex-1"
contentContainerStyle={{ paddingBottom: bottom }}
refreshing={isLoading}
refreshing={isRefetching}
onRefresh={refetch}
sections={sections}
keyExtractor={(item) => item.id}
Expand All @@ -42,7 +36,7 @@ export default function CategoriesScreen() {
renderSectionFooter={({ section }) => (
<>
{!section.data.length &&
(isLoading ? (
(isRefetching ? (
<>
<Skeleton className="mx-6 mb-5 mt-3 h-4 rounded-full" />
<Skeleton className="mx-6 mb-5 mt-3 h-4 rounded-full" />
Expand Down
34 changes: 13 additions & 21 deletions apps/mobile/app/(app)/category/new-category.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,27 @@
import { CategoryForm } from '@/components/category/category-form'
import { createCategory } from '@/mutations/category'
import { categoryQueries } from '@/queries/category'
import type { CategoryTypeType } from '@6pm/validation'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCreateCategory } from '@/stores/category/hooks'
import type { CategoryFormValues, CategoryTypeType } from '@6pm/validation'
import { createId } from '@paralleldrive/cuid2'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { Alert, View } from 'react-native'
import { View } from 'react-native'

export default function CreateCategoryScreen() {
const router = useRouter()
const { type = 'EXPENSE' } = useLocalSearchParams<{
type?: CategoryTypeType
}>()
const queryClient = useQueryClient()
const { mutateAsync } = useMutation({
mutationFn: createCategory,
onError(error) {
Alert.alert(error.message)
},
onSuccess() {
router.back()
},
async onSettled() {
await queryClient.invalidateQueries({
queryKey: categoryQueries.list._def,
})
},
})
const { mutateAsync } = useCreateCategory()

const handleCreate = async (data: CategoryFormValues) => {
mutateAsync({ data, id: createId() }).catch(() => {
// ignore
})
router.back()
}

return (
<View className="py-3 px-6 bg-card h-screen">
<CategoryForm onSubmit={mutateAsync} defaultValues={{ type }} />
<CategoryForm onSubmit={handleCreate} defaultValues={{ type }} />
</View>
)
}
1 change: 1 addition & 0 deletions apps/mobile/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const queryClient = new QueryClient({
queries: {
networkMode: 'offlineFirst',
gcTime: 1000 * 60 * 60 * 24 * 7, // 1 week
staleTime: 1000 * 60 * 60 * 24, // 1 day
},
},
})
20 changes: 13 additions & 7 deletions apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");

const path = require('path');
const path = require("path");

// Find the project and workspace directories
const projectRoot = __dirname;
// This can be replaced with `find-yarn-workspace-root`
const monorepoRoot = path.resolve(projectRoot, '../..');
const monorepoRoot = path.resolve(projectRoot, "../..");

const config = getDefaultConfig(projectRoot);

// 1. Watch all files within the monorepo
config.watchFolders = [monorepoRoot];
// 2. Let Metro know where to resolve packages and in what order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
path.resolve(projectRoot, "node_modules"),
path.resolve(monorepoRoot, "node_modules"),
];

config.resolver.unstable_conditionNames = [
"browser",
"require",
"react-native",
];

config.resolver.unstable_enableSymlinks = true;
config.resolver.unstable_enablePackageExports = true;

module.exports = withNativeWind(config, { input: './global.css' });
module.exports = withNativeWind(config, { input: "./global.css" });
4 changes: 3 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@lingui/macro": "^4.11.1",
"@lingui/react": "^4.11.1",
"@lukemorales/query-key-factory": "^1.3.4",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
Expand Down Expand Up @@ -78,7 +79,8 @@
"react-native-web": "~0.19.10",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
"zod": "^3.23.8",
"zustand": "^4.5.4"
},
"devDependencies": {
"@babel/core": "^7.20.0",
Expand Down
152 changes: 152 additions & 0 deletions apps/mobile/stores/category/hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { getHonoClient } from '@/lib/client'
import { useMeQuery } from '@/queries/auth'
import {
type Category,
type CategoryFormValues,
CategorySchema,
} from '@6pm/validation'
import { createId } from '@paralleldrive/cuid2'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { keyBy, omit } from 'lodash-es'
import { useMemo } from 'react'
import { z } from 'zod'
import { categoryQueries } from './queries'
import { useCategoryStore } from './store'

export const useCategoryList = () => {
const categories = useCategoryStore().categories
const setCategoriesState = useCategoryStore((state) => state.setCategories)

const query = useQuery({
...categoryQueries.all({ setCategoriesState }),
initialData: categories,
})

const { categoriesDict, incomeCategories, expenseCategories } =
useMemo(() => {
const categoriesDict = keyBy(categories, 'id')
const incomeCategories = categories.filter(
(category) => category.type === 'INCOME',
)
const expenseCategories = categories.filter(
(category) => category.type === 'EXPENSE',
)

return {
categoriesDict,
incomeCategories,
expenseCategories,
}
}, [categories])

return {
...query,
categories,
categoriesDict,
incomeCategories,
expenseCategories,
}
}

export const useCategory = (categoryId: string) => {
const categories = useCategoryStore().categories
const category: Category | null = useMemo(
() => categories.find((category) => category.id === categoryId) || null,
[categories, categoryId],
)

return { category }
}

export const useUpdateCategory = () => {
const updateCategoryInStore = useCategoryStore(
(state) => state.updateCategory,
)
const { categoriesDict } = useCategoryList()
const queryClient = useQueryClient()

const mutation = useMutation(
{
mutationFn: async ({
id,
data,
}: { id: string; data: CategoryFormValues }) => {
const hc = await getHonoClient()
const result = await hc.v1.categories[':categoryId'].$put({
param: { categoryId: id },
json: omit(data, 'type'), // prevent updating category type
})

if (result.ok) {
const category = CategorySchema.parse(await result.json())
return category
}

throw result
},
onMutate({ id, data }) {
let category = categoriesDict[id]
if (!category) {
return
}

category = { ...category, ...data, updatedAt: new Date() }

updateCategoryInStore(category)

return category
},
},
queryClient,
)

return mutation
}

export const useCreateCategory = () => {
const { data: userData } = useMeQuery()
const updateCategoryInStore = useCategoryStore(
(state) => state.updateCategory,
)

const mutation = useMutation({
mutationFn: async ({
id = createId(),
data,
}: { id?: string; data: CategoryFormValues }) => {
const hc = await getHonoClient()
const result = await hc.v1.categories.$post({
json: { id, ...data },
})

if (result.ok) {
const json = await result.json()
const category = CategorySchema.extend({
id: z.string(),
}).parse(json)
return category
}

throw result
},
onMutate({ id, data }) {
const category: Category = {
id: id!,
createdAt: new Date(),
updatedAt: new Date(),
parentId: null,
userId: userData?.id || '',
description: '',
color: '',
icon: '',
...data,
}

updateCategoryInStore(category)

return category
},
})

return mutation
}
25 changes: 25 additions & 0 deletions apps/mobile/stores/category/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { getHonoClient } from '@/lib/client'
import { type Category, CategorySchema } from '@6pm/validation'
import { createQueryKeys } from '@lukemorales/query-key-factory'

export const categoryQueries = createQueryKeys('categories', {
all: ({
setCategoriesState,
}: { setCategoriesState: (categories: Category[]) => void }) => ({
queryKey: [{}],
queryFn: async () => {
const hc = await getHonoClient()
const res = await hc.v1.categories.$get()
if (!res.ok) {
throw new Error(await res.text())
}

const items = await res.json()
const categories = items.map((item) => CategorySchema.parse(item))

setCategoriesState(categories)

return categories
},
}),
})
Loading

0 comments on commit 837979e

Please sign in to comment.