Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): update categories to use zustand store #131

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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