feat: progress made on the edit page and its implementation
This commit is contained in:
parent
c11f7c1474
commit
3fa3681c9b
@ -3,10 +3,17 @@ import { Text } from "react-native-paper"
|
|||||||
import { SafeAreaView } from "react-native-safe-area-context"
|
import { SafeAreaView } from "react-native-safe-area-context"
|
||||||
|
|
||||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
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 HabitPage: React.FC = () => {
|
||||||
const { habitId } = useLocalSearchParams()
|
const { habitId } = useLocalSearchParams()
|
||||||
const { habitsTracker } = useHabitsTracker()
|
const { habitsTracker } = useHabitsTracker()
|
||||||
|
const { user } = useAuthentication()
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
|
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
|
||||||
if (habitHistory == null) {
|
if (habitHistory == null) {
|
||||||
@ -25,6 +32,7 @@ const HabitPage: React.FC = () => {
|
|||||||
<Text>
|
<Text>
|
||||||
Habit Page {habitId} {habitHistory.habit.name}
|
Habit Page {habitId} {habitHistory.habit.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
<HabitEditForm user={user} habit={habitHistory.habit} />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 GoalCreateData = z.infer<typeof GoalCreateSchema>
|
||||||
|
|
||||||
|
export type GoalEditData = z.infer<typeof GoalEditSchema>
|
||||||
|
|
||||||
interface GoalBase {
|
interface GoalBase {
|
||||||
frequency: GoalFrequency
|
frequency: GoalFrequency
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,11 @@ export const HabitCreateSchema = HabitSchema.extend({
|
|||||||
}).omit({ id: true })
|
}).omit({ id: true })
|
||||||
export type HabitCreateData = z.infer<typeof HabitCreateSchema>
|
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>
|
type HabitBase = z.infer<typeof HabitSchema>
|
||||||
|
|
||||||
export interface HabitData extends HabitBase {
|
export interface HabitData extends HabitBase {
|
||||||
|
9
domain/repositories/HabitEdit.ts
Normal file
9
domain/repositories/HabitEdit.ts
Normal 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>
|
||||||
|
}
|
23
domain/use-cases/HabitEdit.ts
Normal file
23
domain/use-cases/HabitEdit.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
43
infrastructure/supabase/repositories/HabitEdit.ts
Normal file
43
infrastructure/supabase/repositories/HabitEdit.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,14 @@ export interface HabitsTrackerPresenterState {
|
|||||||
global: ErrorGlobal
|
global: ErrorGlobal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
habitEdit: {
|
||||||
|
state: FetchState
|
||||||
|
errors: {
|
||||||
|
fields: Array<keyof HabitCreateData>
|
||||||
|
global: ErrorGlobal
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HabitsTrackerPresenterOptions {
|
export interface HabitsTrackerPresenterOptions {
|
||||||
@ -52,6 +60,13 @@ export class HabitsTrackerPresenter
|
|||||||
global: null,
|
global: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
habitEdit: {
|
||||||
|
state: "idle",
|
||||||
|
errors: {
|
||||||
|
fields: [],
|
||||||
|
global: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
|
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
|
||||||
this.habitCreateUseCase = habitCreateUseCase
|
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(
|
public async retrieveHabitsTracker(
|
||||||
options: RetrieveHabitsTrackerUseCaseOptions,
|
options: RetrieveHabitsTrackerUseCaseOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
163
presentation/react/components/HabitEditForm/HabitEditForm.tsx
Normal file
163
presentation/react/components/HabitEditForm/HabitEditForm.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
Reference in New Issue
Block a user