Skip to content

Commit

Permalink
Add Todo App
Browse files Browse the repository at this point in the history
  • Loading branch information
garronej committed May 21, 2024
1 parent 906fdad commit f50d47c
Show file tree
Hide file tree
Showing 6 changed files with 269 additions and 0 deletions.
30 changes: 30 additions & 0 deletions src/components/TodoApp/AddTodo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Input
className={className}
label="Add a todo"
addon={
<Button iconId="ri-add-line" onClick={() => props.onAddTodo("todo")}>
Validate
</Button>
}
nativeInputProps={{
value: text,
onChange: e => setText(e.target.value)
}}
/>
);
});
84 changes: 84 additions & 0 deletions src/components/TodoApp/Todo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cx(classes.root, className)}>
<Checkbox checked={todo.isDone} onChange={() => onToggleTodo()} />

<div className={classes.textWrapper}>
{isEditing ? (
<input
className={cx(fr.cx("fr-input"), classes.input)}
value={todo.text}
onChange={e => onUpdateTodoText(e.target.value)}
onBlur={() => setIsEditing(false)}
/>
) : (
<span className={classes.text}>{todo.text}</span>
)}
</div>

<div className={classes.buttonsWrapper}>
<Button
iconId="ri-pencil-line"
onClick={() => setIsEditing(!isEditing)}
priority="secondary"
title="Edit"
/>
<Button
iconId="ri-delete-bin-line"
onClick={() => onDeleteTodo()}
priority="primary"
title="Delete"
/>
</div>
</div>
);
});

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: {}
}));
48 changes: 48 additions & 0 deletions src/components/TodoApp/TodoApp.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cx(classes.root, className)}>
<AddTodo className={classes.addTodo} onAddTodo={onAddTodo} />
<div className={classes.todoListWrapper}>
{todos.map(todo => (
<Todo
key={todo.id}
todo={todo}
onUpdateTodoText={getOnUpdateTodoText(todo.id)}
onToggleTodo={getOnToggleTodo(todo.id)}
onDeleteTodo={getOnDeleteTodo(todo.id)}
/>
))}
</div>
</div>
);
}

const useState = tss.withName({ TodoApp }).create({
root: {},
addTodo: {},
todoListWrapper: {}
});
1 change: 1 addition & 0 deletions src/components/TodoApp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./TodoApp";
56 changes: 56 additions & 0 deletions src/tools/memoize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
type SimpleType = number | string | boolean | null | undefined;
type FuncWithSimpleParams<T extends SimpleType[], R> = (...args: T) => R;

export function memoize<T extends SimpleType[], R>(
fn: FuncWithSimpleParams<T, R>,
options?: {
argsLength?: number;
max?: number;
}
): FuncWithSimpleParams<T, R> {
const cache = new Map<string, ReturnType<FuncWithSimpleParams<T, R>>>();

const { argsLength = fn.length, max = Infinity } = options ?? {};

return ((...args: Parameters<FuncWithSimpleParams<T, R>>) => {
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;
}
50 changes: 50 additions & 0 deletions src/tools/useListCallbacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useRef, useState } from "react";
import { memoize } from "./memoize";
import { id } from "tsafe/id";

export type CallbackFactory<FactoryArgs extends unknown[], Args extends unknown[], R> = (
...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<FactoryArgs extends unknown[], Args extends unknown[], R = void>(
callback: (...callbackArgs: [FactoryArgs, Args]) => R
): CallbackFactory<FactoryArgs, Args, R> {
type Out = CallbackFactory<FactoryArgs, Args, R>;

const callbackRef = useRef<typeof callback>(callback);

callbackRef.current = callback;

const memoizedRef = useRef<Out | undefined>(undefined);

return useState(() =>
id<Out>((...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];
}

0 comments on commit f50d47c

Please sign in to comment.