feat: progress made on the edit page and its implementation

This commit is contained in:
Maxime Rumpler 2024-04-11 12:31:45 +02:00
parent c11f7c1474
commit 3fa3681c9b
8 changed files with 309 additions and 0 deletions

View File

@ -3,10 +3,17 @@ import { Text } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { HabitEditForm } from "@/presentation/react/components/HabitEditForm/HabitEditForm"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const HabitPage: React.FC = () => {
const { habitId } = useLocalSearchParams()
const { habitsTracker } = useHabitsTracker()
const { user } = useAuthentication()
if (user === null) {
return null
}
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
if (habitHistory == null) {
@ -25,6 +32,7 @@ const HabitPage: React.FC = () => {
<Text>
Habit Page {habitId} {habitHistory.habit.name}
</Text>
<HabitEditForm user={user} habit={habitHistory.habit} />
</SafeAreaView>
)
}

View File

@ -33,8 +33,22 @@ export const GoalCreateSchema = z.object({
]),
})
export const GoalEditSchema = z.object({
frequency: goalFrequencyZod,
target: z.discriminatedUnion("type", [
z.object({ type: z.literal("boolean") }),
z.object({
type: z.literal("numeric"),
value: z.number().int().min(0),
unit: z.string().min(1),
}),
]),
})
export type GoalCreateData = z.infer<typeof GoalCreateSchema>
export type GoalEditData = z.infer<typeof GoalEditSchema>
interface GoalBase {
frequency: GoalFrequency
}

View File

@ -15,6 +15,11 @@ export const HabitCreateSchema = HabitSchema.extend({
}).omit({ id: true })
export type HabitCreateData = z.infer<typeof HabitCreateSchema>
export const HabitEditSchema = HabitSchema.extend({
goal: GoalCreateSchema,
})
export type HabitEditData = z.infer<typeof HabitEditSchema>
type HabitBase = z.infer<typeof HabitSchema>
export interface HabitData extends HabitBase {

View File

@ -0,0 +1,9 @@
import type { Habit, HabitEditData } from "../entities/Habit"
export interface HabitEditOptions {
habitEditData: HabitEditData
}
export interface HabitEditRepository {
execute: (options: HabitEditOptions) => Promise<Habit>
}

View File

@ -0,0 +1,23 @@
import type { Habit } from "../entities/Habit"
import { HabitEditSchema } from "../entities/Habit"
import type { HabitEditRepository } from "../repositories/HabitEdit"
export interface HabitEditUseCaseDependencyOptions {
habitEditRepository: HabitEditRepository
}
export class HabitEditUseCase implements HabitEditUseCaseDependencyOptions {
public habitEditRepository: HabitEditRepository
public constructor(options: HabitEditUseCaseDependencyOptions) {
this.habitEditRepository = options.habitEditRepository
}
public async execute(data: unknown): Promise<Habit> {
const habitEditData = await HabitEditSchema.parseAsync(data)
const habit = await this.habitEditRepository.execute({
habitEditData,
})
return habit
}
}

View File

@ -0,0 +1,43 @@
import { Habit } from "@/domain/entities/Habit"
import type { HabitEditRepository } from "@/domain/repositories/HabitEdit"
import { SupabaseRepository } from "./_SupabaseRepository"
import { Goal } from "@/domain/entities/Goal"
export class HabitEditSupabaseRepository
extends SupabaseRepository
implements HabitEditRepository
{
public execute: HabitEditRepository["execute"] = async (options) => {
const { habitEditData } = options
const { data, error } = await this.supabaseClient
.from("habits")
.update({
id: Number(habitEditData.id),
name: habitEditData.name,
color: habitEditData.color,
icon: habitEditData.icon,
goal_frequency: habitEditData.goal.frequency,
...(habitEditData.goal.target.type === "numeric"
? {
goal_target: habitEditData.goal.target.value,
goal_target_unit: habitEditData.goal.target.unit,
}
: {}),
})
.select("*")
const updatedHabit = data?.[0]
if (error != null || updatedHabit == null) {
throw new Error(error?.message ?? "Failed to edit habit.")
}
const habit = new Habit({
id: updatedHabit.id.toString(),
userId: updatedHabit.user_id.toString(),
name: updatedHabit.name,
icon: updatedHabit.icon,
goal: Goal.create(habitEditData.goal),
color: updatedHabit.color,
startDate: new Date(updatedHabit.start_date),
})
return habit
}
}

View File

@ -25,6 +25,14 @@ export interface HabitsTrackerPresenterState {
global: ErrorGlobal
}
}
habitEdit: {
state: FetchState
errors: {
fields: Array<keyof HabitCreateData>
global: ErrorGlobal
}
}
}
export interface HabitsTrackerPresenterOptions {
@ -52,6 +60,13 @@ export class HabitsTrackerPresenter
global: null,
},
},
habitEdit: {
state: "idle",
errors: {
fields: [],
global: null,
},
},
})
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
this.habitCreateUseCase = habitCreateUseCase
@ -86,6 +101,35 @@ export class HabitsTrackerPresenter
}
}
public async habitEdit(data: unknown): Promise<FetchState> {
try {
this.setState((state) => {
state.habitEdit.state = "loading"
state.habitEdit.errors = {
fields: [],
global: null,
}
})
const habit = await this.habitCreateUseCase.execute(data)
this.setState((state) => {
state.habitEdit.state = "success"
state.habitsTracker.addHabit(habit)
})
return "success"
} catch (error) {
this.setState((state) => {
state.habitEdit.state = "error"
if (error instanceof ZodError) {
state.habitEdit.errors.fields =
getErrorsFieldsFromZodError<HabitCreateData>(error)
} else {
state.habitEdit.errors.global = "unknown"
}
})
return "error"
}
}
public async retrieveHabitsTracker(
options: RetrieveHabitsTrackerUseCaseOptions,
): Promise<void> {

View File

@ -0,0 +1,163 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { ScrollView, StyleSheet } from "react-native"
import { Button, HelperText, Snackbar, TextInput } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import ColorPicker, {
HueSlider,
Panel1,
Preview,
} from "reanimated-color-picker"
import type { Habit, HabitEditData } from "@/domain/entities/Habit"
import { HabitEditSchema } from "@/domain/entities/Habit"
import type { User } from "@/domain/entities/User"
import { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitEditFormProps {
user: User
habit: Habit
}
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ user }) => {
const { habitEdit, habitsTrackerPresenter } = useHabitsTracker()
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<HabitEditData>({
mode: "onChange",
resolver: zodResolver(HabitEditSchema),
defaultValues: {
userId: user.id,
name: "",
color: "#006CFF",
icon: "lightbulb",
goal: {
frequency: "daily",
target: {
type: "boolean",
},
},
},
})
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const onDismissSnackbar = (): void => {
setIsVisibleSnackbar(false)
}
const onSubmit = async (data: HabitEditData): Promise<void> => {
await habitsTrackerPresenter.habitEdit(data)
setIsVisibleSnackbar(true)
reset()
}
return (
<SafeAreaView>
<ScrollView
contentContainerStyle={{
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 20,
}}
>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<>
<TextInput
placeholder="Name"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[
styles.spacing,
{
width: "90%",
},
]}
mode="outlined"
/>
{errors.name != null ? (
<HelperText type="error" visible style={[{ paddingTop: 0 }]}>
{errors.name.type === "too_big"
? "Name is too long"
: "Name is required"}
</HelperText>
) : null}
</>
)
}}
name="name"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<ColorPicker
style={[styles.spacing, { width: "90%" }]}
value={value}
onComplete={(value) => {
onChange(value.hex)
}}
>
<Preview hideInitialColor />
<Panel1 />
<HueSlider />
</ColorPicker>
)
}}
name="color"
/>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Icon"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[styles.spacing, { width: "90%" }]}
mode="outlined"
/>
)
}}
name="icon"
/>
<Button
mode="contained"
onPress={handleSubmit(onSubmit)}
loading={habitEdit.state === "loading"}
disabled={habitEdit.state === "loading"}
style={[styles.spacing, { width: "90%" }]}
>
Create your habit! 🚀
</Button>
</ScrollView>
<Snackbar
visible={isVisibleSnackbar}
onDismiss={onDismissSnackbar}
duration={2_000}
>
Habit created successfully!
</Snackbar>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
spacing: {
marginVertical: 16,
},
})