From f50d47cf85df9b8cfee5e1f8bbc1c33dc6702d4e Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Tue, 21 May 2024 18:25:38 +0200 Subject: [PATCH] Add Todo App --- src/components/TodoApp/AddTodo.tsx | 30 +++++++++++ src/components/TodoApp/Todo.tsx | 84 ++++++++++++++++++++++++++++++ src/components/TodoApp/TodoApp.tsx | 48 +++++++++++++++++ src/components/TodoApp/index.ts | 1 + src/tools/memoize.ts | 56 ++++++++++++++++++++ src/tools/useListCallbacks.ts | 50 ++++++++++++++++++ 6 files changed, 269 insertions(+) create mode 100644 src/components/TodoApp/AddTodo.tsx create mode 100644 src/components/TodoApp/Todo.tsx create mode 100644 src/components/TodoApp/TodoApp.tsx create mode 100644 src/components/TodoApp/index.ts create mode 100644 src/tools/memoize.ts create mode 100644 src/tools/useListCallbacks.ts diff --git a/src/components/TodoApp/AddTodo.tsx b/src/components/TodoApp/AddTodo.tsx new file mode 100644 index 0000000..8211cfe --- /dev/null +++ b/src/components/TodoApp/AddTodo.tsx @@ -0,0 +1,30 @@ +import { memo, useState } from "react"; +import { Input } from "@codegouvfr/react-dsfr/Input"; +import { Button } from "@codegouvfr/react-dsfr/Button"; + +type Props = { + className?: string; + onAddTodo: (text: string) => void; +}; + +export const AddTodo = memo((props: Props) => { + const { className } = props; + + const [text, setText] = useState(""); + + return ( + props.onAddTodo("todo")}> + Validate + + } + nativeInputProps={{ + value: text, + onChange: e => setText(e.target.value) + }} + /> + ); +}); diff --git a/src/components/TodoApp/Todo.tsx b/src/components/TodoApp/Todo.tsx new file mode 100644 index 0000000..74f951d --- /dev/null +++ b/src/components/TodoApp/Todo.tsx @@ -0,0 +1,84 @@ +import { memo, useState } from "react"; +import { tss } from "tss-react"; +import { fr } from "@codegouvfr/react-dsfr"; +import { Button } from "@codegouvfr/react-dsfr/Button"; +import Checkbox from "@mui/material/Checkbox"; + +export type Todo = { + id: string; + text: string; + isDone: boolean; +}; + +type TodoProps = { + className?: string; + todo: Todo; + onUpdateTodoText: (text: string) => void; + onToggleTodo: () => void; + onDeleteTodo: () => void; +}; + +export const Todo = memo((props: TodoProps) => { + const { className, todo, onUpdateTodoText, onToggleTodo, onDeleteTodo } = props; + + const [isEditing, setIsEditing] = useState(false); + + const { classes, cx } = useStyles({ isEditing }); + + return ( +
+ onToggleTodo()} /> + +
+ {isEditing ? ( + onUpdateTodoText(e.target.value)} + onBlur={() => setIsEditing(false)} + /> + ) : ( + {todo.text} + )} +
+ +
+
+
+ ); +}); + +const useStyles = tss + .withName({ Todo }) + .withParams<{ isEditing: boolean }>() + .create(({ isEditing }) => ({ + root: { + backgroundColor: isEditing + ? fr.colors.decisions.background.alt.blueFrance.active + : fr.colors.decisions.background.alt.blueFrance.default, + "&:hover": { + backgroundColor: fr.colors.decisions.background.alt.blueFrance.hover + }, + display: "flex", + alignItems: "center", + padding: fr.spacing("2w") + }, + textWrapper: { + flex: 1 + }, + input: {}, + text: {}, + buttonsWrapper: {} + })); diff --git a/src/components/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx new file mode 100644 index 0000000..e4784b5 --- /dev/null +++ b/src/components/TodoApp/TodoApp.tsx @@ -0,0 +1,48 @@ +import { Todo } from "./Todo"; +import { AddTodo } from "./AddTodo"; +import { tss } from "tss-react"; +import { useListCallbacks } from "tools/useListCallbacks"; + +type Props = { + className?: string; + todos: Todo[]; + onAddTodo: (text: string) => void; + onUpdateTodoText: (id: string, text: string) => void; + onToggleTodo: (id: string) => void; + onDeleteTodo: (id: string) => void; +}; + +export function TodoApp(props: Props) { + const { className, todos, onAddTodo, onUpdateTodoText, onToggleTodo, onDeleteTodo } = props; + + const { classes, cx } = useState(); + + const getOnUpdateTodoText = useListCallbacks(([todoId]: [string], [text]: [string]) => + onUpdateTodoText(todoId, text) + ); + const getOnToggleTodo = useListCallbacks(([todoId]: [string]) => onToggleTodo(todoId)); + const getOnDeleteTodo = useListCallbacks(([todoId]: [string]) => onDeleteTodo(todoId)); + + return ( +
+ +
+ {todos.map(todo => ( + + ))} +
+
+ ); +} + +const useState = tss.withName({ TodoApp }).create({ + root: {}, + addTodo: {}, + todoListWrapper: {} +}); diff --git a/src/components/TodoApp/index.ts b/src/components/TodoApp/index.ts new file mode 100644 index 0000000..7f08d8f --- /dev/null +++ b/src/components/TodoApp/index.ts @@ -0,0 +1 @@ +export * from "./TodoApp"; diff --git a/src/tools/memoize.ts b/src/tools/memoize.ts new file mode 100644 index 0000000..9d42fcb --- /dev/null +++ b/src/tools/memoize.ts @@ -0,0 +1,56 @@ +type SimpleType = number | string | boolean | null | undefined; +type FuncWithSimpleParams = (...args: T) => R; + +export function memoize( + fn: FuncWithSimpleParams, + options?: { + argsLength?: number; + max?: number; + } +): FuncWithSimpleParams { + const cache = new Map>>(); + + const { argsLength = fn.length, max = Infinity } = options ?? {}; + + return ((...args: Parameters>) => { + const key = JSON.stringify( + args + .slice(0, argsLength) + .map(v => { + if (v === null) { + return "null"; + } + if (v === undefined) { + return "undefined"; + } + switch (typeof v) { + case "number": + return `number-${v}`; + case "string": + return `string-${v}`; + case "boolean": + return `boolean-${v ? "true" : "false"}`; + } + }) + .join("-sIs9sAslOdeWlEdIos3-") + ); + + if (cache.has(key)) { + return cache.get(key); + } + + if (max === cache.size) { + for (const key of cache.keys()) { + cache.delete(key); + break; + } + } + + const value = fn(...args); + + cache.set(key, value); + + return value; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; +} diff --git a/src/tools/useListCallbacks.ts b/src/tools/useListCallbacks.ts new file mode 100644 index 0000000..2c6f1d9 --- /dev/null +++ b/src/tools/useListCallbacks.ts @@ -0,0 +1,50 @@ +import { useRef, useState } from "react"; +import { memoize } from "./memoize"; +import { id } from "tsafe/id"; + +export type CallbackFactory = ( + ...factoryArgs: FactoryArgs +) => (...args: Args) => R; + +/** + * https://docs.powerhooks.dev/api-reference/useListCallbacks + * + * const callbackFactory= useListCallbacks( + * ([key]: [string], [params]: [{ foo: number; }]) => { + * ... + * }, + * [] + * ); + * + * WARNING: Factory args should not be of variable length. + * + */ +export function useListCallbacks( + callback: (...callbackArgs: [FactoryArgs, Args]) => R +): CallbackFactory { + type Out = CallbackFactory; + + const callbackRef = useRef(callback); + + callbackRef.current = callback; + + const memoizedRef = useRef(undefined); + + return useState(() => + id((...factoryArgs) => { + if (memoizedRef.current === undefined) { + // @ts-expect-error: Todo, figure it out + memoizedRef.current = memoize( + // @ts-expect-error: Todo, figure it out + (...factoryArgs: FactoryArgs) => + (...args: Args) => + callbackRef.current(factoryArgs, args), + { argsLength: factoryArgs.length } + ); + } + + // @ts-expect-error: Todo, figure it out + return memoizedRef.current(...factoryArgs); + }) + )[0]; +}