From 3fa3681c9bafc31ca2681d2eb2ce2ea0a8829dd0 Mon Sep 17 00:00:00 2001 From: Maxime Rumpler Date: Thu, 11 Apr 2024 12:31:45 +0200 Subject: [PATCH 1/2] feat: progress made on the edit page and its implementation --- app/application/habits/[habitId]/index.tsx | 8 + domain/entities/Goal.ts | 14 ++ domain/entities/Habit.ts | 5 + domain/repositories/HabitEdit.ts | 9 + domain/use-cases/HabitEdit.ts | 23 +++ .../supabase/repositories/HabitEdit.ts | 43 +++++ presentation/presenters/HabitsTracker.ts | 44 +++++ .../HabitEditForm/HabitEditForm.tsx | 163 ++++++++++++++++++ 8 files changed, 309 insertions(+) create mode 100644 domain/repositories/HabitEdit.ts create mode 100644 domain/use-cases/HabitEdit.ts create mode 100644 infrastructure/supabase/repositories/HabitEdit.ts create mode 100644 presentation/react/components/HabitEditForm/HabitEditForm.tsx diff --git a/app/application/habits/[habitId]/index.tsx b/app/application/habits/[habitId]/index.tsx index a7fcdec..5a03693 100644 --- a/app/application/habits/[habitId]/index.tsx +++ b/app/application/habits/[habitId]/index.tsx @@ -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 = () => { Habit Page {habitId} {habitHistory.habit.name} + ) } diff --git a/domain/entities/Goal.ts b/domain/entities/Goal.ts index ded0b30..d55fcbf 100644 --- a/domain/entities/Goal.ts +++ b/domain/entities/Goal.ts @@ -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 +export type GoalEditData = z.infer + interface GoalBase { frequency: GoalFrequency } diff --git a/domain/entities/Habit.ts b/domain/entities/Habit.ts index f111484..e9de6c3 100644 --- a/domain/entities/Habit.ts +++ b/domain/entities/Habit.ts @@ -15,6 +15,11 @@ export const HabitCreateSchema = HabitSchema.extend({ }).omit({ id: true }) export type HabitCreateData = z.infer +export const HabitEditSchema = HabitSchema.extend({ + goal: GoalCreateSchema, +}) +export type HabitEditData = z.infer + type HabitBase = z.infer export interface HabitData extends HabitBase { diff --git a/domain/repositories/HabitEdit.ts b/domain/repositories/HabitEdit.ts new file mode 100644 index 0000000..3f5bddd --- /dev/null +++ b/domain/repositories/HabitEdit.ts @@ -0,0 +1,9 @@ +import type { Habit, HabitEditData } from "../entities/Habit" + +export interface HabitEditOptions { + habitEditData: HabitEditData +} + +export interface HabitEditRepository { + execute: (options: HabitEditOptions) => Promise +} diff --git a/domain/use-cases/HabitEdit.ts b/domain/use-cases/HabitEdit.ts new file mode 100644 index 0000000..555b4e3 --- /dev/null +++ b/domain/use-cases/HabitEdit.ts @@ -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 { + const habitEditData = await HabitEditSchema.parseAsync(data) + const habit = await this.habitEditRepository.execute({ + habitEditData, + }) + return habit + } +} diff --git a/infrastructure/supabase/repositories/HabitEdit.ts b/infrastructure/supabase/repositories/HabitEdit.ts new file mode 100644 index 0000000..9020098 --- /dev/null +++ b/infrastructure/supabase/repositories/HabitEdit.ts @@ -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 + } +} diff --git a/presentation/presenters/HabitsTracker.ts b/presentation/presenters/HabitsTracker.ts index b619559..7e0d923 100644 --- a/presentation/presenters/HabitsTracker.ts +++ b/presentation/presenters/HabitsTracker.ts @@ -25,6 +25,14 @@ export interface HabitsTrackerPresenterState { global: ErrorGlobal } } + + habitEdit: { + state: FetchState + errors: { + fields: Array + 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 { + 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(error) + } else { + state.habitEdit.errors.global = "unknown" + } + }) + return "error" + } + } + public async retrieveHabitsTracker( options: RetrieveHabitsTrackerUseCaseOptions, ): Promise { diff --git a/presentation/react/components/HabitEditForm/HabitEditForm.tsx b/presentation/react/components/HabitEditForm/HabitEditForm.tsx new file mode 100644 index 0000000..b26d6e2 --- /dev/null +++ b/presentation/react/components/HabitEditForm/HabitEditForm.tsx @@ -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 = ({ user }) => { + const { habitEdit, habitsTrackerPresenter } = useHabitsTracker() + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + 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 => { + await habitsTrackerPresenter.habitEdit(data) + setIsVisibleSnackbar(true) + reset() + } + + return ( + + + { + return ( + <> + + {errors.name != null ? ( + + {errors.name.type === "too_big" + ? "Name is too long" + : "Name is required"} + + ) : null} + + ) + }} + name="name" + /> + + { + return ( + { + onChange(value.hex) + }} + > + + + + + ) + }} + name="color" + /> + + { + return ( + + ) + }} + name="icon" + /> + + + + + + ✅ Habit created successfully! + + + ) +} + +const styles = StyleSheet.create({ + spacing: { + marginVertical: 16, + }, +}) From 246cbe918a2a0b344e2602f67142a6d0ae2d5ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LUDWIG?= Date: Thu, 11 Apr 2024 13:07:17 +0200 Subject: [PATCH 2/2] feat: edit habit working version --- app/application/habits/[habitId]/index.tsx | 25 +++---------------- app/application/habits/new.tsx | 2 +- domain/entities/Goal.ts | 14 ----------- domain/entities/Habit.ts | 4 +-- domain/entities/HabitsTracker.ts | 8 ++++++ infrastructure/instances.ts | 9 +++++++ .../supabase/repositories/HabitEdit.ts | 24 +++++++++++------- presentation/presenters/HabitsTracker.ts | 20 ++++++++++----- .../HabitEditForm/HabitEditForm.tsx | 25 ++++++------------- 9 files changed, 59 insertions(+), 72 deletions(-) diff --git a/app/application/habits/[habitId]/index.tsx b/app/application/habits/[habitId]/index.tsx index 5a03693..02c162c 100644 --- a/app/application/habits/[habitId]/index.tsx +++ b/app/application/habits/[habitId]/index.tsx @@ -1,39 +1,20 @@ import { Redirect, useLocalSearchParams } from "expo-router" -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" +import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker" 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) { return } return ( - - - Habit Page {habitId} {habitHistory.habit.name} - - - + ) } diff --git a/app/application/habits/new.tsx b/app/application/habits/new.tsx index f2c5361..f410ab0 100644 --- a/app/application/habits/new.tsx +++ b/app/application/habits/new.tsx @@ -4,7 +4,7 @@ import { useAuthentication } from "@/presentation/react/contexts/Authentication" const NewHabitPage: React.FC = () => { const { user } = useAuthentication() - if (user === null) { + if (user == null) { return null } diff --git a/domain/entities/Goal.ts b/domain/entities/Goal.ts index d55fcbf..ded0b30 100644 --- a/domain/entities/Goal.ts +++ b/domain/entities/Goal.ts @@ -33,22 +33,8 @@ 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 -export type GoalEditData = z.infer - interface GoalBase { frequency: GoalFrequency } diff --git a/domain/entities/Habit.ts b/domain/entities/Habit.ts index e9de6c3..d80ba59 100644 --- a/domain/entities/Habit.ts +++ b/domain/entities/Habit.ts @@ -15,9 +15,7 @@ export const HabitCreateSchema = HabitSchema.extend({ }).omit({ id: true }) export type HabitCreateData = z.infer -export const HabitEditSchema = HabitSchema.extend({ - goal: GoalCreateSchema, -}) +export const HabitEditSchema = HabitSchema.extend({}) export type HabitEditData = z.infer type HabitBase = z.infer diff --git a/domain/entities/HabitsTracker.ts b/domain/entities/HabitsTracker.ts index 7a99215..19f2122 100644 --- a/domain/entities/HabitsTracker.ts +++ b/domain/entities/HabitsTracker.ts @@ -35,6 +35,14 @@ export class HabitsTracker implements HabitsTrackerData { ) } + public editHabit(habit: Habit): void { + const habitHistory = this.getHabitHistoryById(habit.id) + if (habitHistory == null) { + return + } + habitHistory.habit = habit + } + public getAllHabitsHistory(): HabitHistory[] { return [ ...this.habitsHistory.daily, diff --git a/infrastructure/instances.ts b/infrastructure/instances.ts index 89b1690..8e92055 100644 --- a/infrastructure/instances.ts +++ b/infrastructure/instances.ts @@ -8,6 +8,8 @@ import { supabaseClient } from "./supabase/supabase" import { AuthenticationPresenter } from "@/presentation/presenters/Authentication" import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate" import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate" +import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit" +import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit" /** * Repositories @@ -25,6 +27,9 @@ const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({ const habitCreateRepository = new HabitCreateSupabaseRepository({ supabaseClient, }) +const habitEditRepository = new HabitEditSupabaseRepository({ + supabaseClient, +}) /** * Use Cases @@ -39,6 +44,9 @@ const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({ getHabitProgressHistoryRepository: getHabitProgressesRepository, getHabitsByUserIdRepository, }) +const habitEditUseCase = new HabitEditUseCase({ + habitEditRepository, +}) /** * Presenters @@ -49,4 +57,5 @@ export const authenticationPresenter = new AuthenticationPresenter({ export const habitsTrackerPresenter = new HabitsTrackerPresenter({ retrieveHabitsTrackerUseCase, habitCreateUseCase, + habitEditUseCase, }) diff --git a/infrastructure/supabase/repositories/HabitEdit.ts b/infrastructure/supabase/repositories/HabitEdit.ts index 9020098..f482cc3 100644 --- a/infrastructure/supabase/repositories/HabitEdit.ts +++ b/infrastructure/supabase/repositories/HabitEdit.ts @@ -12,18 +12,11 @@ export class HabitEditSupabaseRepository 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, - } - : {}), }) + .eq("id", habitEditData.id) .select("*") const updatedHabit = data?.[0] if (error != null || updatedHabit == null) { @@ -34,7 +27,20 @@ export class HabitEditSupabaseRepository userId: updatedHabit.user_id.toString(), name: updatedHabit.name, icon: updatedHabit.icon, - goal: Goal.create(habitEditData.goal), + goal: Goal.create({ + frequency: updatedHabit.goal_frequency, + target: + updatedHabit.goal_target != null && + updatedHabit.goal_target_unit != null + ? { + type: "numeric", + value: updatedHabit.goal_target, + unit: updatedHabit.goal_target_unit, + } + : { + type: "boolean", + }, + }), color: updatedHabit.color, startDate: new Date(updatedHabit.start_date), }) diff --git a/presentation/presenters/HabitsTracker.ts b/presentation/presenters/HabitsTracker.ts index 7e0d923..9688adb 100644 --- a/presentation/presenters/HabitsTracker.ts +++ b/presentation/presenters/HabitsTracker.ts @@ -7,9 +7,10 @@ import type { RetrieveHabitsTrackerUseCase, RetrieveHabitsTrackerUseCaseOptions, } from "@/domain/use-cases/RetrieveHabitsTracker" -import type { HabitCreateData } from "@/domain/entities/Habit" +import type { HabitCreateData, HabitEditData } from "@/domain/entities/Habit" import { getErrorsFieldsFromZodError } from "../../utils/zod" import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate" +import type { HabitEditUseCase } from "@/domain/use-cases/HabitEdit" export interface HabitsTrackerPresenterState { habitsTracker: HabitsTracker @@ -29,7 +30,7 @@ export interface HabitsTrackerPresenterState { habitEdit: { state: FetchState errors: { - fields: Array + fields: Array global: ErrorGlobal } } @@ -38,6 +39,7 @@ export interface HabitsTrackerPresenterState { export interface HabitsTrackerPresenterOptions { retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase habitCreateUseCase: HabitCreateUseCase + habitEditUseCase: HabitEditUseCase } export class HabitsTrackerPresenter @@ -46,9 +48,14 @@ export class HabitsTrackerPresenter { public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase public habitCreateUseCase: HabitCreateUseCase + public habitEditUseCase: HabitEditUseCase public constructor(options: HabitsTrackerPresenterOptions) { - const { retrieveHabitsTrackerUseCase, habitCreateUseCase } = options + const { + retrieveHabitsTrackerUseCase, + habitCreateUseCase, + habitEditUseCase, + } = options const habitsTracker = HabitsTracker.default() super({ habitsTracker, @@ -70,6 +77,7 @@ export class HabitsTrackerPresenter }) this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase this.habitCreateUseCase = habitCreateUseCase + this.habitEditUseCase = habitEditUseCase } public async habitCreate(data: unknown): Promise { @@ -110,10 +118,10 @@ export class HabitsTrackerPresenter global: null, } }) - const habit = await this.habitCreateUseCase.execute(data) + const habit = await this.habitEditUseCase.execute(data) this.setState((state) => { state.habitEdit.state = "success" - state.habitsTracker.addHabit(habit) + state.habitsTracker.editHabit(habit) }) return "success" } catch (error) { @@ -121,7 +129,7 @@ export class HabitsTrackerPresenter state.habitEdit.state = "error" if (error instanceof ZodError) { state.habitEdit.errors.fields = - getErrorsFieldsFromZodError(error) + getErrorsFieldsFromZodError(error) } else { state.habitEdit.errors.global = "unknown" } diff --git a/presentation/react/components/HabitEditForm/HabitEditForm.tsx b/presentation/react/components/HabitEditForm/HabitEditForm.tsx index b26d6e2..be69d7f 100644 --- a/presentation/react/components/HabitEditForm/HabitEditForm.tsx +++ b/presentation/react/components/HabitEditForm/HabitEditForm.tsx @@ -12,36 +12,28 @@ import ColorPicker, { 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 = ({ user }) => { +export const HabitEditForm: React.FC = ({ habit }) => { const { habitEdit, habitsTrackerPresenter } = useHabitsTracker() const { control, handleSubmit, - reset, formState: { errors }, } = useForm({ mode: "onChange", resolver: zodResolver(HabitEditSchema), defaultValues: { - userId: user.id, - name: "", - color: "#006CFF", - icon: "lightbulb", - goal: { - frequency: "daily", - target: { - type: "boolean", - }, - }, + id: habit.id, + userId: habit.userId, + name: habit.name, + color: habit.color, + icon: habit.icon, }, }) @@ -54,7 +46,6 @@ export const HabitEditForm: React.FC = ({ user }) => { const onSubmit = async (data: HabitEditData): Promise => { await habitsTrackerPresenter.habitEdit(data) setIsVisibleSnackbar(true) - reset() } return ( @@ -141,7 +132,7 @@ export const HabitEditForm: React.FC = ({ user }) => { disabled={habitEdit.state === "loading"} style={[styles.spacing, { width: "90%" }]} > - Create your habit! 🚀 + Save @@ -150,7 +141,7 @@ export const HabitEditForm: React.FC = ({ user }) => { onDismiss={onDismissSnackbar} duration={2_000} > - ✅ Habit created successfully! + ✅ Habit Saved successfully! )