refactor: separate react/react-native

This commit is contained in:
Théo LUDWIG 2024-05-02 01:08:27 +02:00
parent 748ac2476c
commit f3156eee61
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
30 changed files with 204 additions and 27 deletions

View File

@ -1,7 +1,7 @@
import { Redirect, Tabs } from "expo-router" import { Redirect, Tabs } from "expo-router"
import React from "react" import React from "react"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon" import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication" import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => { const TabLayout: React.FC = () => {

View File

@ -1,6 +1,6 @@
import { Redirect, useLocalSearchParams } from "expo-router" import { Redirect, useLocalSearchParams } from "expo-router"
import { HabitEditForm } from "@/presentation/react/components/HabitForm/HabitEditForm" import { HabitEditForm } from "@/presentation/react-native/components/HabitForm/HabitEditForm"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker" import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
const HabitPage: React.FC = () => { const HabitPage: React.FC = () => {

View File

@ -4,11 +4,11 @@ import { Agenda } from "react-native-calendars"
import { Text } from "react-native-paper" import { Text } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context" import { SafeAreaView } from "react-native-safe-area-context"
import { getISODate, getNowDate } from "@/utils/dates" import { getISODate, getNowDateUTC } from "@/utils/dates"
const HistoryPage: React.FC = () => { const HistoryPage: React.FC = () => {
const today = useMemo(() => { const today = useMemo(() => {
return getNowDate() return getNowDateUTC()
}, []) }, [])
const todayISO = getISODate(today) const todayISO = getISODate(today)

View File

@ -1,7 +1,7 @@
import { SafeAreaView } from "react-native-safe-area-context" import { SafeAreaView } from "react-native-safe-area-context"
import { ActivityIndicator, Button, Text } from "react-native-paper" import { ActivityIndicator, Button, Text } from "react-native-paper"
import { HabitsMainPage } from "@/presentation/react/components/HabitsMainPage/HabitsMainPage" import { HabitsMainPage } from "@/presentation/react-native/components/HabitsMainPage/HabitsMainPage"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker" import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { useAuthentication } from "@/presentation/react/contexts/Authentication" import { useAuthentication } from "@/presentation/react/contexts/Authentication"

View File

@ -1,4 +1,4 @@
import { HabitCreateForm } from "@/presentation/react/components/HabitForm/HabitCreateForm" import { HabitCreateForm } from "@/presentation/react-native/components/HabitForm/HabitCreateForm"
import { useAuthentication } from "@/presentation/react/contexts/Authentication" import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const NewHabitPage: React.FC = () => { const NewHabitPage: React.FC = () => {

View File

@ -1,7 +1,7 @@
import { Redirect, Tabs } from "expo-router" import { Redirect, Tabs } from "expo-router"
import React from "react" import React from "react"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon" import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication" import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => { const TabLayout: React.FC = () => {

View File

@ -1,10 +1,18 @@
import { createClient } from "@supabase/supabase-js" import {
createClient,
type User as SupabaseUserType,
} from "@supabase/supabase-js"
import { AppState, Platform } from "react-native" import { AppState, Platform } from "react-native"
import "react-native-url-polyfill/auto" import "react-native-url-polyfill/auto"
import AsyncStorage from "@react-native-async-storage/async-storage" import AsyncStorage from "@react-native-async-storage/async-storage"
import type { Database } from "./supabase-types" import type { Database } from "./supabase-types"
export type SupabaseUser = SupabaseUserType
export type SupabaseHabit = Database["public"]["Tables"]["habits"]["Row"]
export type SupabaseHabitProgress =
Database["public"]["Tables"]["habits_progresses"]["Row"]
const SUPABASE_URL = const SUPABASE_URL =
process.env["EXPO_PUBLIC_SUPABASE_URL"] ?? process.env["EXPO_PUBLIC_SUPABASE_URL"] ??
"https://wjtwtzxreersqfvfgxrz.supabase.co" "https://wjtwtzxreersqfvfgxrz.supabase.co"

View File

@ -1,7 +1,7 @@
{ {
"preset": "jest-expo", "preset": "jest-expo",
"roots": ["./"], "roots": ["./"],
"setupFilesAfterEnv": ["@testing-library/react-native/extend-expect"], "setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
"fakeTimers": { "fakeTimers": {
"enableGlobally": true "enableGlobally": true
}, },
@ -10,7 +10,7 @@
"coverageReporters": ["text", "text-summary", "cobertura"], "coverageReporters": ["text", "text-summary", "cobertura"],
"collectCoverageFrom": [ "collectCoverageFrom": [
"<rootDir>/**/*.{ts,tsx}", "<rootDir>/**/*.{ts,tsx}",
"!<rootDir>/presentation/react/components/ExternalLink.tsx", "!<rootDir>/presentation/react-native/ui/ExternalLink.tsx",
"!<rootDir>/.expo", "!<rootDir>/.expo",
"!<rootDir>/app/+html.tsx", "!<rootDir>/app/+html.tsx",
"!<rootDir>/app/**/_layout.tsx", "!<rootDir>/app/**/_layout.tsx",

View File

@ -24,8 +24,8 @@ import { GOAL_FREQUENCIES, GOAL_TYPES } from "@/domain/entities/Goal"
import type { HabitCreateData } from "@/domain/entities/Habit" import type { HabitCreateData } from "@/domain/entities/Habit"
import { HabitCreateSchema } from "@/domain/entities/Habit" import { HabitCreateSchema } from "@/domain/entities/Habit"
import type { User } from "@/domain/entities/User" import type { User } from "@/domain/entities/User"
import { useHabitsTracker } from "../../contexts/HabitsTracker" import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { useBoolean } from "../../hooks/useBoolean" import { useBoolean } from "@/presentation/react/hooks/useBoolean"
import { IconSelectorModal } from "./IconSelectorModal" import { IconSelectorModal } from "./IconSelectorModal"
export interface HabitCreateFormProps { export interface HabitCreateFormProps {
@ -37,11 +37,10 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
const { const {
control, control,
formState: { errors, isValid },
handleSubmit, handleSubmit,
reset, reset,
watch, watch,
formState: { errors, isValid },
} = useForm<HabitCreateData>({ } = useForm<HabitCreateData>({
mode: "onChange", mode: "onChange",
resolver: zodResolver(HabitCreateSchema), resolver: zodResolver(HabitCreateSchema),

View File

@ -20,8 +20,8 @@ import ColorPicker, {
import type { Habit, HabitEditData } from "@/domain/entities/Habit" import type { Habit, HabitEditData } from "@/domain/entities/Habit"
import { HabitEditSchema } from "@/domain/entities/Habit" import { HabitEditSchema } from "@/domain/entities/Habit"
import { useHabitsTracker } from "../../contexts/HabitsTracker" import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { useBoolean } from "../../hooks/useBoolean" import { useBoolean } from "@/presentation/react/hooks/useBoolean"
import { IconSelectorModal } from "./IconSelectorModal" import { IconSelectorModal } from "./IconSelectorModal"
export interface HabitEditFormProps { export interface HabitEditFormProps {
@ -33,8 +33,8 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
const { const {
control, control,
handleSubmit,
formState: { errors, isValid }, formState: { errors, isValid },
handleSubmit,
} = useForm<HabitEditData>({ } = useForm<HabitEditData>({
mode: "onChange", mode: "onChange",
resolver: zodResolver(HabitEditSchema), resolver: zodResolver(HabitEditSchema),

View File

@ -3,7 +3,7 @@ import { Agenda } from "react-native-calendars"
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal" import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker" import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
import { getISODate, getNowDate } from "@/utils/dates" import { getISODate, getNowDateUTC } from "@/utils/dates"
import { HabitsEmpty } from "./HabitsEmpty" import { HabitsEmpty } from "./HabitsEmpty"
import { HabitsList } from "./HabitsList" import { HabitsList } from "./HabitsList"
@ -14,7 +14,7 @@ export interface HabitsMainPageProps {
export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => { export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
const { habitsTracker } = props const { habitsTracker } = props
const today = getNowDate() const today = getNowDateUTC()
const todayISO = getISODate(today) const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState<Date>(today) const [selectedDate, setSelectedDate] = useState<Date>(today)

View File

@ -1,6 +1,6 @@
import renderer from "react-test-renderer" import renderer from "react-test-renderer"
import { ExternalLink } from "@/presentation/react/components/ExternalLink" import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
describe("<ExternalLink />", () => { describe("<ExternalLink />", () => {
it("renders correctly", () => { it("renders correctly", () => {

View File

@ -1,6 +1,6 @@
import renderer from "react-test-renderer" import renderer from "react-test-renderer"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon" import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
describe("<TabBarIcon />", () => { describe("<TabBarIcon />", () => {
it("renders correctly", () => { it("renders correctly", () => {

View File

@ -2,8 +2,8 @@ import { act, renderHook } from "@testing-library/react-native"
import { useBoolean } from "@/presentation/react/hooks/useBoolean" import { useBoolean } from "@/presentation/react/hooks/useBoolean"
describe("hooks/useBoolean", () => { describe("presentation/react/hooks/useBoolean", () => {
beforeEach(() => { afterEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
@ -11,51 +11,76 @@ describe("hooks/useBoolean", () => {
for (const initialValue of initialValues) { for (const initialValue of initialValues) {
it(`should set the initial value to ${initialValue}`, () => { it(`should set the initial value to ${initialValue}`, () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean({ initialValue }) return useBoolean({ initialValue })
}) })
// Assert - Then
expect(result.current.value).toBe(initialValue) expect(result.current.value).toBe(initialValue)
}) })
} }
it("should by default set the initial value to false", () => { it("should by default set the initial value to false", () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean() return useBoolean()
}) })
// Assert - Then
expect(result.current.value).toBe(false) expect(result.current.value).toBe(false)
}) })
it("should toggle the value", async () => { it("should toggle the value", async () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean({ initialValue: false }) return useBoolean({ initialValue: false })
}) })
// Act - When
await act(() => { await act(() => {
return result.current.toggle() return result.current.toggle()
}) })
// Assert - Then
expect(result.current.value).toBe(true) expect(result.current.value).toBe(true)
// Act - When
await act(() => { await act(() => {
return result.current.toggle() return result.current.toggle()
}) })
// Assert - Then
expect(result.current.value).toBe(false) expect(result.current.value).toBe(false)
}) })
it("should set the value to true", async () => { it("should set the value to true", async () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean({ initialValue: false }) return useBoolean({ initialValue: false })
}) })
// Act - When
await act(() => { await act(() => {
return result.current.setTrue() return result.current.setTrue()
}) })
// Assert - Then
expect(result.current.value).toBe(true) expect(result.current.value).toBe(true)
}) })
it("should set the value to false", async () => { it("should set the value to false", async () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean({ initialValue: true }) return useBoolean({ initialValue: true })
}) })
// Act - When
await act(() => { await act(() => {
return result.current.setFalse() return result.current.setFalse()
}) })
// Assert - Then
expect(result.current.value).toBe(false) expect(result.current.value).toBe(false)
}) })
}) })

View File

@ -0,0 +1,75 @@
import { act, renderHook } from "@testing-library/react-native"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
import { Presenter } from "@/presentation/presenters/_Presenter"
interface MockCountPresenterState {
count: number
}
class MockCountPresenter extends Presenter<MockCountPresenterState> {
public constructor(initialState: MockCountPresenterState) {
super(initialState)
}
public increment(): void {
this.setState((state) => {
state.count = state.count + 1
})
}
}
describe("presentation/react/hooks/usePresenterState", () => {
it("should return the initial state from the presenter", async () => {
// Arrange - Given
const initialState = { count: 0 }
const presenter = new MockCountPresenter(initialState)
// Act - When
const { result } = renderHook(() => {
return usePresenterState(presenter)
})
// Assert - Then
expect(result.current).toEqual(initialState)
})
it("should update state when presenter state changes", async () => {
// Arrange - Given
const initialState = { count: 0 }
const presenter = new MockCountPresenter(initialState)
const subscribe = jest.spyOn(presenter, "subscribe")
const { result } = renderHook(() => {
return usePresenterState(presenter)
})
// Act - When
await act(() => {
presenter.increment()
})
// Assert - Then
expect(result.current.count).toBe(1)
expect(subscribe).toHaveBeenCalledTimes(1)
})
it("should unsubscribe from presenter on unmount", async () => {
// Arrange - Given
const initialState = { count: 0 }
const presenter = new MockCountPresenter(initialState)
const unsubscribe = jest.spyOn(presenter, "unsubscribe")
const { result, unmount } = renderHook(() => {
return usePresenterState(presenter)
})
// Act - When
unmount()
await act(() => {
presenter.increment()
})
// Assert - Then
expect(result.current.count).toBe(0)
expect(unsubscribe).toHaveBeenCalledTimes(1)
})
})

View File

@ -2,9 +2,10 @@ import { useState } from "react"
export interface UseBooleanResult { export interface UseBooleanResult {
value: boolean value: boolean
toggle: () => void setValue: React.Dispatch<React.SetStateAction<boolean>>
setTrue: () => void setTrue: () => void
setFalse: () => void setFalse: () => void
toggle: () => void
} }
export interface UseBooleanOptions { export interface UseBooleanOptions {
@ -43,6 +44,7 @@ export const useBoolean = (
return { return {
value, value,
setValue,
toggle, toggle,
setTrue, setTrue,
setFalse, setFalse,

1
tests/setup.ts Normal file
View File

@ -0,0 +1 @@
import "@testing-library/react-native/extend-expect"

View File

@ -1,6 +1,12 @@
import { getISODate, getWeekNumber } from "../dates" import { getISODate, getNowDateUTC, getWeekNumber } from "../dates"
describe("utils/dates", () => { describe("utils/dates", () => {
afterEach(() => {
jest.clearAllMocks()
jest.resetAllMocks()
jest.useRealTimers()
})
describe("getISODate", () => { describe("getISODate", () => {
it("should return the correct date in ISO format (e.g: 2012-05-23)", () => { it("should return the correct date in ISO format (e.g: 2012-05-23)", () => {
// Arrange - Given // Arrange - Given
@ -15,6 +21,25 @@ describe("utils/dates", () => {
}) })
}) })
describe("getNowDateUTC", () => {
it("should return the current UTC date", () => {
// Arrange - Given
const mockDate = new Date("2024-05-01T12:00:00Z")
jest.useFakeTimers({ now: mockDate })
Date.UTC = jest.fn(() => {
return mockDate.getTime()
})
// Act - When
const result = getNowDateUTC()
// Assert - Then
const expected = new Date("2024-05-01T12:00:00.000Z")
expect(result).toEqual(expected)
expect(Date.UTC).toHaveBeenCalledTimes(1)
})
})
describe("getWeekNumber", () => { describe("getWeekNumber", () => {
it("should return the correct week number for a given date (e.g: 2020-01-01)", () => { it("should return the correct week number for a given date (e.g: 2020-01-01)", () => {
// Arrange - Given // Arrange - Given

View File

@ -0,0 +1,39 @@
import type { ZodIssue } from "zod"
import { ZodError } from "zod"
import { getErrorsFieldsFromZodError } from "../zod"
const zodIssue: ZodIssue = {
code: "too_small",
minimum: 1,
type: "string",
inclusive: true,
exact: false,
message: "String must contain at least 1 character(s)",
path: ["name"],
}
describe("utils/zod", () => {
describe("getErrorsFieldsFromZodError", () => {
it("should return an array of the fields that have errors", () => {
// Arrange - Given
const error = new ZodError([
{
...zodIssue,
path: ["field1"],
},
{
...zodIssue,
path: ["field2"],
},
])
// Act - When
const result = getErrorsFieldsFromZodError(error)
// Assert - Then
const expected = ["field1", "field2"]
expect(result).toEqual(expected)
})
})
})

View File

@ -11,7 +11,7 @@ export const getISODate = (date: Date): string => {
return date.toISOString().slice(0, 10) return date.toISOString().slice(0, 10)
} }
export const getNowDate = (): Date => { export const getNowDateUTC = (): Date => {
const date = new Date() const date = new Date()
const milliseconds = Date.UTC( const milliseconds = Date.UTC(
date.getFullYear(), date.getFullYear(),

View File

@ -3,5 +3,8 @@ import type { ZodError } from "zod"
export const getErrorsFieldsFromZodError = <T>( export const getErrorsFieldsFromZodError = <T>(
error: ZodError<T>, error: ZodError<T>,
): Array<keyof T> => { ): Array<keyof T> => {
return Object.keys(error.format()) as Array<keyof T> const fields = Object.keys(error.format()) as Array<keyof T>
return fields.filter((field) => {
return field !== "_errors"
})
} }