1
1
mirror of https://github.com/theoludwig/p61-project.git synced 2024-07-17 07:00:12 +02:00

Merge branch 'feat/habit-edit-page' into 'develop'

feat: habit edit page

See merge request rrll/p61-project!4
This commit is contained in:
RUMPLER MAXIME 2024-04-11 11:58:46 +00:00
commit 49e8460e5c
10 changed files with 313 additions and 17 deletions

View File

@ -1,7 +1,6 @@
import { Redirect, useLocalSearchParams } from "expo-router"
import { Text } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { HabitEditForm } from "@/presentation/react/components/HabitEditForm/HabitEditForm"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
const HabitPage: React.FC = () => {
@ -9,23 +8,13 @@ const HabitPage: React.FC = () => {
const { habitsTracker } = useHabitsTracker()
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
if (habitHistory == null) {
return <Redirect href="/application/habits/" />
}
return (
<SafeAreaView
style={[
{
flex: 1,
alignItems: "center",
},
]}
>
<Text>
Habit Page {habitId} {habitHistory.habit.name}
</Text>
</SafeAreaView>
<HabitEditForm habit={habitHistory.habit} key={habitHistory.habit.id} />
)
}

View File

@ -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
}

View File

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

View File

@ -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,

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

@ -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,
})

View File

@ -0,0 +1,49 @@
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({
name: habitEditData.name,
color: habitEditData.color,
icon: habitEditData.icon,
})
.eq("id", habitEditData.id)
.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({
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),
})
return habit
}
}

View File

@ -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
@ -25,11 +26,20 @@ export interface HabitsTrackerPresenterState {
global: ErrorGlobal
}
}
habitEdit: {
state: FetchState
errors: {
fields: Array<keyof HabitEditData>
global: ErrorGlobal
}
}
}
export interface HabitsTrackerPresenterOptions {
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
habitCreateUseCase: HabitCreateUseCase
habitEditUseCase: HabitEditUseCase
}
export class HabitsTrackerPresenter
@ -38,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,
@ -52,9 +67,17 @@ export class HabitsTrackerPresenter
global: null,
},
},
habitEdit: {
state: "idle",
errors: {
fields: [],
global: null,
},
},
})
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
this.habitCreateUseCase = habitCreateUseCase
this.habitEditUseCase = habitEditUseCase
}
public async habitCreate(data: unknown): Promise<FetchState> {
@ -86,6 +109,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.habitEditUseCase.execute(data)
this.setState((state) => {
state.habitEdit.state = "success"
state.habitsTracker.editHabit(habit)
})
return "success"
} catch (error) {
this.setState((state) => {
state.habitEdit.state = "error"
if (error instanceof ZodError) {
state.habitEdit.errors.fields =
getErrorsFieldsFromZodError<HabitEditData>(error)
} else {
state.habitEdit.errors.global = "unknown"
}
})
return "error"
}
}
public async retrieveHabitsTracker(
options: RetrieveHabitsTrackerUseCaseOptions,
): Promise<void> {

View File

@ -0,0 +1,154 @@
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 { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitEditFormProps {
habit: Habit
}
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
const { habitEdit, habitsTrackerPresenter } = useHabitsTracker()
const {
control,
handleSubmit,
formState: { errors },
} = useForm<HabitEditData>({
mode: "onChange",
resolver: zodResolver(HabitEditSchema),
defaultValues: {
id: habit.id,
userId: habit.userId,
name: habit.name,
color: habit.color,
icon: habit.icon,
},
})
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const onDismissSnackbar = (): void => {
setIsVisibleSnackbar(false)
}
const onSubmit = async (data: HabitEditData): Promise<void> => {
await habitsTrackerPresenter.habitEdit(data)
setIsVisibleSnackbar(true)
}
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%" }]}
>
Save
</Button>
</ScrollView>
<Snackbar
visible={isVisibleSnackbar}
onDismiss={onDismissSnackbar}
duration={2_000}
>
Habit Saved successfully!
</Snackbar>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
spacing: {
marginVertical: 16,
},
})