Refined the design a bit

This commit is contained in:
Kasper Juul Hermansen 2021-11-14 01:50:18 +01:00
parent 62630d63f2
commit ce3d351769
Signed by: kjuulh
GPG Key ID: 0F95C140730F2F23
18 changed files with 315 additions and 97 deletions

View File

@ -16,20 +16,16 @@ public class TodosController : ControllerBase
} }
[HttpPost] [HttpPost]
public async Task<ActionResult<Core.Entities.Todo>> CreateTodo([FromBody] CreateTodoRequest request) public async Task<ActionResult<Core.Entities.Todo>> CreateTodo([FromBody] CreateTodoRequest request) =>
{ Ok(await _todoRepository.CreateTodoAsync(request.Title));
var todo = await _todoRepository.CreateTodoAsync(request.Title);
return Ok(todo);
}
[HttpGet] [HttpGet]
public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>> GetTodos() public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>> GetTodos() =>
{ Ok(await _todoRepository.GetTodosAsync());
var todos = await _todoRepository.GetTodosAsync();
return Ok(todos); [HttpGet]
} public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>> GetNotDoneTodos() =>
Ok(await _todoRepository.GetNotDoneTodos());
public record CreateTodoRequest public record CreateTodoRequest
{ {

View File

@ -18,36 +18,47 @@ namespace Todo.Api.Hubs
{ {
var _ = await _todoRepository.CreateTodoAsync(todoTitle); var _ = await _todoRepository.CreateTodoAsync(todoTitle);
var todos = await _todoRepository.GetTodosAsync(); var todos = await _todoRepository.GetNotDoneTodos();
var serializedTodos = var serializedTodos =
JsonSerializer.Serialize(todos JsonSerializer.Serialize(todos
.Select(t => new TodoResponse { Id = t.Id, Title = t.Title }) .Select(t => new TodoResponse { Id = t.Id, Title = t.Title })
.ToList()); .ToList());
await Clients.Caller.SendAsync("todos", serializedTodos); await Clients.Caller.SendAsync("getInboxTodos", serializedTodos);
} }
public async Task UpdateTodo(string todoId, bool todoStatus) public async Task UpdateTodo(string todoId, bool todoStatus)
{ {
await _todoRepository.UpdateTodoStatus(todoId, todoStatus); await _todoRepository.UpdateTodoStatus(todoId, todoStatus);
var todos = await _todoRepository.GetTodosAsync(); var todos = await _todoRepository.GetNotDoneTodos();
var serializedTodos = var serializedTodos =
JsonSerializer.Serialize(todos JsonSerializer.Serialize(todos
.Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status }) .Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status })
.ToList()); .ToList());
await Clients.Caller.SendAsync("todos", serializedTodos); await Clients.Caller.SendAsync("getInboxTodos", serializedTodos);
} }
public async Task GetTodos() public async Task GetTodos()
{ {
var todos = await _todoRepository.GetTodosAsync(); var todos = await _todoRepository.GetTodosAsync();
var serializedTodos = JsonSerializer.Serialize(todos var serializedTodos = JsonSerializer.Serialize(todos
.Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status}) .Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status })
.ToList()); .ToList());
await Clients.Caller.SendAsync("todos", serializedTodos); await Clients.Caller.SendAsync("todos", serializedTodos);
} }
public async Task GetInboxTodos()
{
var todos = await _todoRepository.GetNotDoneTodos();
var serializedTodos = JsonSerializer.Serialize(todos
.Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status })
.ToList());
await Clients.Caller.SendAsync("getInboxTodos", serializedTodos);
}
} }
} }

View File

@ -5,4 +5,5 @@ public interface ITodoRepository
Task<Entities.Todo> CreateTodoAsync(string title); Task<Entities.Todo> CreateTodoAsync(string title);
Task<IEnumerable<Entities.Todo>> GetTodosAsync(); Task<IEnumerable<Entities.Todo>> GetTodosAsync();
Task UpdateTodoStatus(string todoId, bool todoStatus); Task UpdateTodoStatus(string todoId, bool todoStatus);
Task<IEnumerable<Entities.Todo>> GetNotDoneTodos();
} }

View File

@ -20,7 +20,7 @@ public class TodoRepository : ITodoRepository
{ {
var todo = new MongoTodo() { Title = title }; var todo = new MongoTodo() { Title = title };
await _todosCollection.InsertOneAsync(todo); await _todosCollection.InsertOneAsync(todo);
return new Core.Entities.Todo() { Id = todo.Id, Title = todo.Title }; return new Core.Entities.Todo() { Id = todo.Id, Title = todo.Title, Status = false};
} }
public async Task<IEnumerable<Core.Entities.Todo>> GetTodosAsync() public async Task<IEnumerable<Core.Entities.Todo>> GetTodosAsync()
@ -38,4 +38,10 @@ public class TodoRepository : ITodoRepository
.UpdateOneAsync(t => t.Id == todoId, .UpdateOneAsync(t => t.Id == todoId,
Builders<MongoTodo>.Update.Set(t => t.Status, todoStatus)); Builders<MongoTodo>.Update.Set(t => t.Status, todoStatus));
} }
public async Task<IEnumerable<Core.Entities.Todo>> GetNotDoneTodos()
{
var todos = await GetTodosAsync();
return todos.Where(t => t.Status == false);
}
} }

View File

@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<package-manager value="yarn" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@ -2,9 +2,11 @@ import { useState } from "react";
import { CollapsedAddTodo } from "@src/components/todos/collapsed/collapsedAddTodo"; import { CollapsedAddTodo } from "@src/components/todos/collapsed/collapsedAddTodo";
import { AddTodoForm } from "@src/components/todos/collapsed/addTodoForm"; import { AddTodoForm } from "@src/components/todos/collapsed/addTodoForm";
import { CollapsedState } from "@src/components/todos/collapsed/collapsedState"; import { CollapsedState } from "@src/components/todos/collapsed/collapsedState";
import {HubConnection} from "@microsoft/signalr"; import { useCreateTodo } from "@src/presentation/hooks/socketHooks";
export function AddTodo(props: {}) {
const { createTodo } = useCreateTodo();
export function AddTodo(props: {conn: HubConnection}) {
const [collapsed, setCollapsed] = useState<CollapsedState>( const [collapsed, setCollapsed] = useState<CollapsedState>(
CollapsedState.collapsed CollapsedState.collapsed
); );
@ -20,7 +22,7 @@ export function AddTodo(props: {conn: HubConnection}) {
return ( return (
<AddTodoForm <AddTodoForm
onAdd={(todoName) => { onAdd={(todoName) => {
props.conn.invoke("CreateTodo", todoName).catch(console.error) createTodo(todoName);
}} }}
onClose={() => setCollapsed(CollapsedState.collapsed)} onClose={() => setCollapsed(CollapsedState.collapsed)}
/> />

View File

@ -7,29 +7,44 @@ export const AddTodoForm: FC<{
const [todoName, setTodoName] = useState(""); const [todoName, setTodoName] = useState("");
return ( return (
<div className="p-2 space-y-1"> <form
<div className="todo-input-form py-2 px-4 bg-gray-900 border border-gray-600"> onSubmit={(e) => {
e.preventDefault();
onAdd(todoName);
setTodoName("");
}}
>
<div className="py-2 space-y-3">
<div className="todo-input-form py-2 px-4 bg-gray-800 border border-gray-500 rounded-lg">
<input <input
type="text" type="text"
placeholder="Todo name" placeholder="Todo name"
className="text-sm" className="text-sm"
autoFocus
value={todoName} value={todoName}
tabIndex={1}
onChange={(e) => setTodoName(e.target.value)} onChange={(e) => setTodoName(e.target.value)}
/> />
</div> </div>
<div className="space-x-2"> <div className="space-x-2">
<button <button
type="submit"
tabIndex={2}
disabled={!todoName} disabled={!todoName}
onClick={() => { className="base-button dark:bg-accent-500 disabled:bg-accent-800 active:bg-accent-400"
onAdd(todoName);
setTodoName("");
}}
className="disabled:text-gray-800 transition"
> >
Add todo Add todo
</button> </button>
<button onClick={onClose}>Cancel</button> <button
tabIndex={3}
type="button"
className="base-button"
onClick={onClose}
>
Cancel
</button>
</div> </div>
</div> </div>
</form>
); );
}; };

View File

@ -1,5 +1,12 @@
import { FC } from "react"; import { FC } from "react";
export const CollapsedAddTodo: FC<{ onClick: () => void }> = ({ onClick }) => { export const CollapsedAddTodo: FC<{ onClick: () => void }> = ({ onClick }) => {
return <div onClick={onClick}>Add todo</div>; return (
<div
className="cursor-pointer select-none dark:text-gray-400"
onClick={onClick}
>
Add todo
</div>
);
}; };

View File

@ -11,7 +11,7 @@ export const TodoCheckmark: FC<TodoCheckmarkProps> = (props) => (
onClick={() => onClick={() =>
props.updateTodo({ ...props.todo, status: !props.todo.status }) props.updateTodo({ ...props.todo, status: !props.todo.status })
} }
className={`h-5 w-5 rounded-full border dark:border-gray-700 ${ className={`h-5 w-5 rounded-full border dark:border-gray-500 ${
props.todo.status === StatusState.done props.todo.status === StatusState.done
? "dark:bg-gray-700" ? "dark:bg-gray-700"
: "hover:dark:bg-gray-600" : "hover:dark:bg-gray-600"

View File

@ -8,7 +8,7 @@ interface TodoItemProps {
} }
export const TodoItem: FC<TodoItemProps> = (props) => ( export const TodoItem: FC<TodoItemProps> = (props) => (
<div className="py-3 border-b border-gray-300 dark:border-gray-700"> <div className="py-3 border-b border-gray-300 dark:border-gray-600">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<TodoCheckmark {...props} /> <TodoCheckmark {...props} />
<span className="pb-1">{props.todo.title}</span> <span className="pb-1">{props.todo.title}</span>

View File

@ -1,24 +1,28 @@
import { Todo } from "@src/core/entities/todo"; import { Todo } from "@src/core/entities/todo";
import { TodoItem } from "@src/components/todos/todoItem"; import { TodoItem } from "@src/components/todos/todoItem";
import { AddTodo } from "@src/components/todos/addTodo"; import { AddTodo } from "@src/components/todos/addTodo";
import { HubConnection } from "@microsoft/signalr"; import { useUpdateTodoState } from "@src/presentation/hooks/socketHooks";
export const TodoList = (props: { todos: Todo[]; conn: HubConnection }) => ( export const TodoList = (props: { todos: Todo[]; hideDone: boolean }) => {
const { updateTodoState } = useUpdateTodoState();
return (
<> <>
<ul id="inbox"> <ul id="inbox">
{props.todos.map((t, i) => ( {props.todos
.filter((t) => t.status == false)
.map((t, i) => (
<li key={i}> <li key={i}>
<TodoItem <TodoItem
todo={t} todo={t}
updateTodo={(todo) => { updateTodo={(todo) => {
props.conn updateTodoState(todo.id, todo.status);
.invoke("UpdateTodo", todo.id, todo.status)
.catch(console.error);
}} }}
/> />
</li> </li>
))} ))}
</ul> </ul>
<AddTodo conn={props.conn} /> <AddTodo />
</> </>
); );
};

View File

@ -1,6 +1,6 @@
type NotDone = false; type NotDone = false;
type Done = true; type Done = true;
type StatusState = NotDone | Done; export type StatusState = NotDone | Done;
export const StatusState: { done: Done; notDone: NotDone } = { export const StatusState: { done: Done; notDone: NotDone } = {
done: true, done: true,
notDone: false, notDone: false,
@ -11,3 +11,19 @@ export interface Todo {
title: string; title: string;
status: StatusState; status: StatusState;
} }
export const asTodo = (item: Todo): Todo => {
if (!item.id) {
throw new Error("Validation failed: id is null");
}
if (!item.title) {
throw new Error("Validation failed: title is null");
}
if (!!item.status) {
throw new Error("Validation failed: status is null");
}
return item;
};

View File

@ -1,9 +1,11 @@
import { AppProps } from "next/app"; import { AppProps } from "next/app";
import "@src/styles/tailwind.css"; import "@src/styles/tailwind.css";
import SocketProvider from "@src/presentation/contexts/SocketContext";
const MyApp = ({ Component, pageProps }: AppProps) => ( const MyApp = ({ Component, pageProps }: AppProps) => (
<SocketProvider>
<Component {...pageProps} /> <Component {...pageProps} />
</SocketProvider>
); );
export default MyApp; export default MyApp;

View File

@ -1,34 +1,15 @@
import { useEffect, useState } from "react";
import { PageHeading } from "@src/components/common/headings/pageHeading"; import { PageHeading } from "@src/components/common/headings/pageHeading";
import { TodoList } from "@src/components/todos"; import { TodoList } from "@src/components/todos";
import * as signalR from "@microsoft/signalr"; import { useSelectInboxTodos } from "@src/presentation/hooks/socketHooks";
import { HubConnection } from "@microsoft/signalr";
import { Todo } from "@src/core/entities/todo";
const HomePage = () => { const HomePage = () => {
const [conn, setConn] = useState<HubConnection>(); const { todos, loading } = useSelectInboxTodos();
const [todos, setTodos] = useState<Todo[]>([]);
useEffect(() => {
const connection = new signalR.HubConnectionBuilder()
.withUrl("http://localhost:5000/hubs/todo")
.build();
connection.on("todos", (todos) => {
const parsedTodos = JSON.parse(todos);
setTodos(parsedTodos);
});
connection.start().then(() => connection.invoke("GetTodos"));
setConn(connection);
}, []);
return ( return (
<main className="py-8 px-14 space-y-6"> <main className="py-8 px-14 space-y-6">
<section className="space-y-2"> <section className="space-y-2 mx-auto max-w-4xl">
<PageHeading title="Inbox" /> <PageHeading title="Inbox" />
<TodoList todos={todos} conn={conn} /> <TodoList todos={loading ? [] : todos} hideDone />
</section> </section>
</main> </main>
); );

View File

@ -0,0 +1,85 @@
import { createContext, FC, useEffect, useState } from "react";
import {
HubConnection,
HubConnectionBuilder,
LogLevel,
} from "@microsoft/signalr";
import { asTodo, StatusState, Todo } from "@src/core/entities/todo";
interface SocketContextProps {
conn: HubConnection;
todos: Todo[];
inboxTodos: Todo[];
getTodos: () => void;
getInboxTodos: () => void;
createTodo: (todoName: string) => void;
updateTodo: (todoId: string, todoStatus: StatusState) => void;
}
export const SocketContext = createContext<SocketContextProps>({
conn: void 0,
todos: [],
inboxTodos: [],
getTodos: () => {},
getInboxTodos: () => {},
createTodo: (todoName) => {},
updateTodo: (todoId, todoStatus) => {},
});
export const SocketProvider: FC = (props) => {
const [conn, setConn] = useState<HubConnection>();
const [todos, setTodos] = useState<Todo[]>([]);
const [inboxTodos, setInboxTodos] = useState<Todo[]>([]);
useEffect(() => {
const connection = new HubConnectionBuilder()
.withUrl("http://localhost:5000/hubs/todo")
.withAutomaticReconnect()
.configureLogging(LogLevel.Information)
.build();
connection.on("getTodos", (rawTodos) => {
const newTodos = JSON.parse(rawTodos) as Todo[];
const validatedTodos = newTodos.map(asTodo);
setTodos(validatedTodos);
});
connection.on("getInboxTodos", (rawTodos) => {
const newTodos = JSON.parse(rawTodos) as Todo[];
const validatedTodos = newTodos
.map(asTodo)
.filter((t) => t.status == false);
setInboxTodos(validatedTodos);
});
connection.start().then(() => {
setConn(connection);
});
}, []);
return (
<SocketContext.Provider
value={{
conn,
todos,
inboxTodos,
getTodos: () => {
conn.invoke("GetTodos").catch(console.error);
},
getInboxTodos: () => {
conn.invoke("GetInboxTodos").catch(console.error);
},
createTodo: (todoName) => {
conn.invoke("CreateTodo", todoName).catch(console.error);
},
updateTodo: (todoId, todoStatus) => {
conn.invoke("UpdateTodo", todoId, todoStatus).catch(console.error);
},
}}
>
{conn ? props.children : "loading"}
</SocketContext.Provider>
);
};
export default SocketProvider;

View File

@ -0,0 +1,36 @@
import { useContext, useEffect } from "react";
import { SocketContext } from "@src/presentation/contexts/SocketContext";
import { StatusState } from "@src/core/entities/todo";
export const useSelectInboxTodos = () => {
const socketContext = useContext(SocketContext);
useEffect(() => {
socketContext.getInboxTodos();
}, []);
return {
loading: false,
todos: socketContext.inboxTodos,
};
};
export const useCreateTodo = () => {
const socketContext = useContext(SocketContext);
return {
createTodo: (todoName: string) => {
socketContext.createTodo(todoName);
},
};
};
export const useUpdateTodoState = () => {
const socketContext = useContext(SocketContext);
return {
updateTodoState: (todoId: string, todoState: StatusState) => {
socketContext.updateTodo(todoId, todoState);
},
};
};

View File

@ -2,7 +2,7 @@
@tailwind components; @tailwind components;
input { input {
@apply shadow-none placeholder-gray-300 dark:placeholder-gray-700 border-none outline-none; @apply shadow-none placeholder-gray-50 dark:placeholder-gray-500 border-none outline-none;
background: transparent; background: transparent;
} }
@ -12,10 +12,26 @@ input {
@tailwind utilities; @tailwind utilities;
.base-button {
@apply py-2
px-4
rounded-lg
transition
bg-gray-700
disabled:bg-gray-800
active:bg-gray-600
border
border-gray-500
disabled:border-gray-600
text-sm
font-semibold
disabled:text-gray-300
}
* { * {
@apply dark:text-gray-100 @apply dark:text-gray-50
} }
body { body {
@apply h-screen dark:bg-black @apply h-screen dark:bg-gray-700
} }

View File

@ -5,7 +5,34 @@ module.exports = {
}, },
darkMode: "media", // or 'media' or 'class' darkMode: "media", // or 'media' or 'class'
theme: { theme: {
extend: {}, extend: {
colors: {
gray: {
50: "#eaeaea",
100: "#d4d4d5",
200: "#bfbfc0",
300: "#a9aaab",
400: "#949596",
500: "#535557",
600: "#3d3f42",
700: "#282A2D",
800: "#1c1d1f",
900: "#141517",
},
accent: {
50: "#fcf6e7",
100: "#faeccf",
200: "#f5d99e",
300: "#efc66e",
400: "#eab33d",
500: "#e5a00d",
600: "#b7800a",
700: "#896008",
800: "#5c4005",
900: "#2e2003",
},
},
},
}, },
variants: { variants: {
extend: {}, extend: {},