Add redux
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing

This commit is contained in:
Kasper Juul Hermansen 2021-11-18 23:00:24 +01:00
parent 7db2ceca08
commit 7712f63999
Signed by: kjuulh
GPG Key ID: 0F95C140730F2F23
28 changed files with 501 additions and 218 deletions

View File

@ -7,5 +7,5 @@ namespace Todo.Api.Controllers;
[ApiController] [ApiController]
public class ApiController : ControllerBase public class ApiController : ControllerBase
{ {
public IMediator Mediator => HttpContext.RequestServices.GetRequiredService<IMediator>(); protected IMediator Mediator => HttpContext.RequestServices.GetRequiredService<IMediator>();
} }

View File

@ -47,8 +47,33 @@ public class TodosController : ApiController
public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>> GetTodos() public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>> GetTodos()
=> Ok(await _todoRepository.GetTodosAsync()); => Ok(await _todoRepository.GetTodosAsync());
[HttpGet("not-done")] [HttpPut("{todoId}")]
public async Task<ActionResult<IEnumerable<Core.Entities.Todo>>> public async Task<ActionResult> ReplaceTodo([FromRoute] string todoId, [FromBody] ReplaceTodoRequest request)
GetNotDoneTodos() {
=> Ok(await _todoRepository.GetNotDoneTodos()); await Mediator.Send(request.To(todoId));
return NoContent();
}
public record ReplaceTodoRequest
{
[Required]
[JsonPropertyName("title")]
public string Title { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("project")]
public string? Project { get; init; }
[JsonPropertyName("status")]
public bool Status { get; init; }
internal ReplaceTodoCommand To(string id) => new(
id,
Title,
Project,
Description,
Status);
}
} }

View File

@ -27,8 +27,9 @@ public class TodoPublisher : ITodoPublisher
_logger = logger; _logger = logger;
} }
public async Task Publish( public async Task Publish<T>(
string todoId, string eventType,
T message,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var userId = _currentUserService.GetUserId() ?? var userId = _currentUserService.GetUserId() ??
@ -36,14 +37,33 @@ public class TodoPublisher : ITodoPublisher
var connections = var connections =
await _userConnectionStore.GetConnectionsAsync(userId); await _userConnectionStore.GetConnectionsAsync(userId);
switch (eventType)
{
case "todoCreated":
await _hubContext await _hubContext
.Clients .Clients
.Clients(connections) .Clients(connections)
.SendAsync( .SendAsync(
"todoCreated", "todoCreated",
todoId, message,
cancellationToken); cancellationToken);
break;
_logger.LogInformation("todo created {TodoId}", todoId); case "todoUpdated":
await _hubContext
.Clients
.Clients(connections)
.SendAsync(
"todoUpdated",
message,
cancellationToken);
break;
}
_logger.LogInformation(
"todo event: {EventType} with message: {Message}",
eventType,
message);
} }
} }

View File

@ -0,0 +1,55 @@
using System.Threading;
using MediatR;
using Todo.Core.Application.Notifications.Todo;
using Todo.Core.Interfaces.Persistence;
using Todo.Core.Interfaces.User;
namespace Todo.Core.Application.Commands.Todo;
public record ReplaceTodoCommand(
string Id,
string Title,
string Project,
string Description,
bool Status) : IRequest<Unit>
{
internal class Handler : IRequestHandler<ReplaceTodoCommand, Unit>
{
private readonly ICurrentUserService _currentUserService;
private readonly ITodoRepository _todoRepository;
private readonly IMediator _mediator;
public Handler(
ICurrentUserService currentUserService,
ITodoRepository todoRepository,
IMediator mediator)
{
_currentUserService = currentUserService;
_todoRepository = todoRepository;
_mediator = mediator;
}
public async Task<Unit> Handle(ReplaceTodoCommand request, CancellationToken cancellationToken)
{
var userId = _currentUserService.GetUserId();
if (userId is null)
throw new InvalidOperationException("User was not found");
//TODO: Make sure the author actually owns the todo
await _todoRepository.UpdateTodoAsync(request.To(userId));
await _mediator.Publish(new TodoUpdated(request.Id), cancellationToken);
return Unit.Value;
}
}
private Entities.Todo To(string authorId) => new()
{
Id = Id,
Description = Description,
Project = Project,
Status = Status,
Title = Title,
AuthorId = authorId
};
}

View File

@ -20,6 +20,7 @@ public record TodoCreated(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await _todoPublisher.Publish( await _todoPublisher.Publish(
"todoCreated",
JsonSerializer.Serialize(notification), JsonSerializer.Serialize(notification),
cancellationToken); cancellationToken);
} }

View File

@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using MediatR;
using Todo.Core.Interfaces.Publisher;
namespace Todo.Core.Application.Notifications.Todo;
public record TodoUpdated(
[property: JsonPropertyName("todoId")] string TodoId) : INotification
{
internal class Handler : INotificationHandler<TodoUpdated>
{
private readonly ITodoPublisher _todoPublisher;
public Handler(ITodoPublisher todoPublisher) => _todoPublisher = todoPublisher;
public async Task Handle(
TodoUpdated notification,
CancellationToken cancellationToken)
{
await _todoPublisher.Publish(
"todoUpdated",
JsonSerializer.Serialize(notification),
cancellationToken);
}
}
}

View File

@ -4,5 +4,5 @@ namespace Todo.Core.Interfaces.Publisher;
public interface ITodoPublisher public interface ITodoPublisher
{ {
Task Publish(string todoId, CancellationToken cancellationToken = new()); Task Publish<T>(string eventType, T message, CancellationToken cancellationToken = new());
} }

View File

@ -16,4 +16,4 @@ public record TodoViewModel(
t.AuthorId, t.AuthorId,
t.Project, t.Project,
t.Description); t.Description);
}; }

View File

@ -39,7 +39,14 @@ internal class InMemoryUserConnectionStore : IUserConnectionStore
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds); ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds);
// remove the connection id from the List // remove the connection id from the List
try
{
existingUserConnectionIds?.Remove(connectionId); existingUserConnectionIds?.Remove(connectionId);
}
catch (Exception e)
{
Console.WriteLine(e);
}
// If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers). // If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers).
if (existingUserConnectionIds?.Count == 0) if (existingUserConnectionIds?.Count == 0)

View File

@ -1,3 +1,4 @@
using System.Diagnostics;
using MongoDB.Driver; using MongoDB.Driver;
using Todo.Core.Interfaces.Persistence; using Todo.Core.Interfaces.Persistence;
using Todo.Persistence.Mongo.Repositories.Dtos; using Todo.Persistence.Mongo.Repositories.Dtos;
@ -35,7 +36,8 @@ public class TodoRepository : ITodoRepository
Title = todo.Title, Title = todo.Title,
Status = false, Status = false,
Project = todo.ProjectName, Project = todo.ProjectName,
Description = todo.Description Description = todo.Description,
AuthorId = todo.AuthorId
}; };
} }
@ -52,7 +54,8 @@ public class TodoRepository : ITodoRepository
Title = t.Title, Title = t.Title,
Status = t.Status, Status = t.Status,
Project = t.ProjectName, Project = t.ProjectName,
Description = t.Description Description = t.Description,
AuthorId = t.AuthorId
}); });
} }
@ -84,7 +87,8 @@ public class TodoRepository : ITodoRepository
Project = todo.ProjectName, Project = todo.ProjectName,
Status = todo.Status, Status = todo.Status,
Title = todo.Title, Title = todo.Title,
Description = todo.Description Description = todo.Description,
AuthorId = todo.AuthorId
}; };
} }

View File

@ -9,17 +9,20 @@
}, },
"dependencies": { "dependencies": {
"@microsoft/signalr": "^6.0.0", "@microsoft/signalr": "^6.0.0",
"@reduxjs/toolkit": "^1.6.2",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"axios": "^0.24.0", "axios": "^0.24.0",
"next": "12.0.3", "next": "12.0.3",
"next-pwa": "^5.4.0", "next-pwa": "^5.4.0",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-redux": "^7.2.6",
"react-textarea-autosize": "^8.3.3", "react-textarea-autosize": "^8.3.3",
"tailwindcss": "^2.2.19" "tailwindcss": "^2.2.19"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^17.0.34", "@types/react": "^17.0.34",
"@types/react-redux": "^7.1.20",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"cssnano": "^5.0.10", "cssnano": "^5.0.10",
"eslint": "7", "eslint": "7",

View File

@ -1,31 +0,0 @@
import axios from "axios";
import { Todo } from "@src/core/entities/todo";
const getBaseUrl = () =>
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5000";
const createBaseClient = () =>
axios.create({
baseURL: getBaseUrl(),
withCredentials: true,
});
interface CreateTodoRequest {
title: string;
description?: string;
project?: string;
}
export const createTodoAsync = async (
createTodo: CreateTodoRequest
): Promise<string> =>
await createBaseClient().post<string, string, CreateTodoRequest>(
"/api/todos",
createTodo
);
interface GetTodoByIdResponse extends Todo {}
export const getTodoByIdAsync = async (todoId: string) =>
await createBaseClient().get<any, GetTodoByIdResponse>(
`/api/todos/${todoId}`
);

View File

@ -10,7 +10,7 @@ export const PrimaryButton: FC<ButtonHTMLAttributes<HTMLButtonElement>> = (
tabIndex={2} tabIndex={2}
disabled={props.disabled} disabled={props.disabled}
className={ className={
"base-button bg-accent-500 disabled:bg-accent-800 active:bg-accent-400 border-none text-white " + "base-button bg-accent-500 disabled:bg-accent-800 dark:bg-accent-500 active:bg-accent-400 border-none text-white " +
props.className props.className
} }
> >

View File

@ -3,6 +3,7 @@ import { FC, useState } from "react";
import { TodoCheckmark } from "@src/components/todos/todoCheckmark"; import { TodoCheckmark } from "@src/components/todos/todoCheckmark";
import Tippy from "@tippyjs/react"; import Tippy from "@tippyjs/react";
import Link from "next/link"; import Link from "next/link";
import { todosApi } from "@src/infrastructure/apis/todosApi";
interface TodoItemProps { interface TodoItemProps {
todo: Todo; todo: Todo;
@ -14,10 +15,15 @@ export const TodoItem: FC<TodoItemProps> = (props) => {
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
const [menuIsOpen, setMenuIsOpen] = useState(false); const [menuIsOpen, setMenuIsOpen] = useState(false);
const prefetchTodo = todosApi.usePrefetch("getTodoById");
return ( return (
<div <div
className="py-3 border-b border-gray-300 dark:border-gray-600 relative" className="py-3 border-b border-gray-300 dark:border-gray-600 relative"
onMouseEnter={() => setIsHovering(true)} onMouseEnter={() => {
prefetchTodo(props.todo.id);
setIsHovering(true);
}}
onMouseLeave={() => setIsHovering(false)} onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsHovering(true)} onFocus={() => setIsHovering(true)}
> >
@ -32,7 +38,7 @@ export const TodoItem: FC<TodoItemProps> = (props) => {
<div className="flex flex-row justify-between items-end"> <div className="flex flex-row justify-between items-end">
<div className="flex flex-row"> <div className="flex flex-row">
{props.todo.description && ( {props.todo.description && (
<div className="h-3 w-3 bg-gray-100 dark:border-black border border-gray-300 dark:bg-gray-900 rounded-sm"/> <div className="h-3 w-3 bg-gray-100 dark:border-black border border-gray-300 dark:bg-gray-900 rounded-sm" />
)} )}
</div> </div>
{props.displayProject && props.todo.project && ( {props.displayProject && props.todo.project && (

View File

@ -1,7 +1,7 @@
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 { useUpdateTodoState } from "@src/presentation/hooks/socketHooks"; import { useUpdateTodo } from "@src/presentation/hooks/socketHooks";
export const TodoList = (props: { export const TodoList = (props: {
todos: Todo[]; todos: Todo[];
@ -9,19 +9,23 @@ export const TodoList = (props: {
hideProject: boolean; hideProject: boolean;
project: string; project: string;
}) => { }) => {
const { updateTodoState } = useUpdateTodoState(); const { updateTodo } = useUpdateTodo();
return ( return (
<> <>
<ul id="inbox"> <ul id="inbox">
{props.todos {props.todos
.filter((t) => t.status == false) .filter((t) => {
if (!props.hideDone) return true;
return t.status == false;
})
.map((t, i) => ( .map((t, i) => (
<li key={i}> <li key={i}>
<TodoItem <TodoItem
todo={t} todo={t}
updateTodo={(todo) => { updateTodo={(todo) => {
updateTodoState(todo.id, todo.status); updateTodo(todo);
}} }}
displayProject={!props.hideProject} displayProject={!props.hideProject}
/> />

View File

@ -0,0 +1,16 @@
export const groupByProjects = (arr) => {
if (!arr) {
return {};
}
const criteria = "project";
return arr.reduce(function (acc, currentValue) {
const currentValueElement = currentValue[criteria] || "inbox";
if (!acc[currentValueElement]) {
acc[currentValueElement] = [];
}
acc[currentValueElement].push(currentValue);
return acc;
}, {});
};

View File

@ -0,0 +1,63 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithReauth } from "@src/infrastructure/apis/utilities";
import { Todo } from "@src/core/entities/todo";
export const todosApi = createApi({
reducerPath: "api",
tagTypes: ["todo"],
baseQuery: baseQueryWithReauth,
endpoints: (build) => ({
getTodoById: build.query<Todo, string>({
query: (id) => `api/todos/${id}`,
providesTags: (result, error, id) => [{ type: "todo", id }],
}),
getAllTodos: build.query<Todo[], void>({
query: () => ({ url: `api/todos` }),
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: "todo" as const, id })),
{ type: "todo", id: "LIST" },
]
: [],
}),
createTodo: build.mutation<string, Partial<Todo>>({
query(body) {
return {
url: "/api/todos",
method: "POST",
body,
};
},
invalidatesTags: (result, error, id) => [{ type: "todo", id: "LIST" }],
}),
replaceTodo: build.mutation<string, Partial<Todo>>({
query(data) {
const { id, ...body } = data;
return {
url: `/api/todos/${id}`,
method: "PUT",
body,
};
},
invalidatesTags: (result, error, { id }) => [{ type: "todo", id }],
async onQueryStarted({ id, ...body }, { dispatch, queryFulfilled }) {
const replaceResult = dispatch(
todosApi.util.updateQueryData("getTodoById", id, (draft) => {
Object.assign(draft, body);
})
);
try {
await queryFulfilled;
} catch {
replaceResult.undo();
dispatch(todosApi.util.invalidateTags([{ type: "todo", id }]));
}
},
}),
}),
});

View File

@ -0,0 +1,27 @@
import {
BaseQueryFn,
FetchArgs,
fetchBaseQuery,
FetchBaseQueryError,
} from "@reduxjs/toolkit/query/react";
export const getApiBaseUrl = () =>
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5000";
const baseQuery = fetchBaseQuery({
baseUrl: getApiBaseUrl(),
credentials: "include",
});
export const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions);
if (result.error && result.error.status === 302) {
window.location.href = `${getApiBaseUrl()}/api/auth/login?returnUrl=${
window.location.href
}`;
}
return result;
};

View File

@ -0,0 +1,23 @@
import { createEntityAdapter, createSlice } from "@reduxjs/toolkit";
import { Todo } from "@src/core/entities/todo";
import { AppState } from "@src/infrastructure/store";
export const todosAdapter = createEntityAdapter<Todo>({
selectId: (todo) => todo.id,
sortComparer: (a, b) => a.title.localeCompare(b.title),
});
export const todosSlice = createSlice({
name: "todo",
initialState: todosAdapter.getInitialState(),
reducers: {
todoAdded: todosAdapter.addOne,
todosReceived(state, action) {
todosAdapter.setAll(state, action.payload.todos);
},
},
});
export const todosSelectors = todosAdapter.getSelectors<AppState>(
(state) => state.todos
);

View File

@ -0,0 +1,6 @@
import { AppDispatch, AppState } from "@src/infrastructure/store/store";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;

View File

@ -0,0 +1,4 @@
export * from "./hooks";
export * from "./store";
export { default } from "./store";

View File

@ -0,0 +1,30 @@
import { Action, configureStore, ThunkAction } from "@reduxjs/toolkit";
import { todosSlice } from "@src/infrastructure/state/todo";
import { todosApi } from "@src/infrastructure/apis/todosApi";
import { setupListeners } from "@reduxjs/toolkit/query";
export function makeStore() {
return configureStore({
reducer: {
todos: todosSlice.reducer,
[todosApi.reducerPath]: todosApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(todosApi.middleware),
});
}
const store = makeStore();
setupListeners(store.dispatch);
export type AppState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
AppState,
unknown,
Action<string>
>;
export default store;

View File

@ -1,8 +1,10 @@
import { AppProps } from "next/app"; import type { AppProps } from "next/app";
import Head from "next/head"; import Head from "next/head";
import "@src/styles/tailwind.css"; import "@src/styles/tailwind.css";
import SocketProvider from "@src/presentation/contexts/SocketContext"; import SocketProvider from "@src/presentation/contexts/SocketContext";
import store from "@src/infrastructure/store";
import { Provider } from "react-redux";
const MyApp = ({ Component, pageProps }: AppProps) => ( const MyApp = ({ Component, pageProps }: AppProps) => (
<> <>
@ -34,9 +36,11 @@ const MyApp = ({ Component, pageProps }: AppProps) => (
<meta name="theme-color" content="#317EFB" /> <meta name="theme-color" content="#317EFB" />
</Head> </Head>
<Provider store={store}>
<SocketProvider> <SocketProvider>
<Component {...pageProps} /> <Component {...pageProps} />
</SocketProvider> </SocketProvider>
</Provider>
</> </>
); );
export default MyApp; export default MyApp;

View File

@ -1,40 +1,33 @@
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 { useSelectInboxTodos } from "@src/presentation/hooks/socketHooks";
import { ProjectsHeading } from "@src/components/common/headings/projectsHeading"; import { ProjectsHeading } from "@src/components/common/headings/projectsHeading";
import { groupByProjects } from "@src/core/actions/utility/groupByProjects";
import { todosApi } from "@src/infrastructure/apis/todosApi";
import { AddTodo } from "@src/components/todos/addTodo"; import { AddTodo } from "@src/components/todos/addTodo";
const groupByProjects = (arr) => {
if (!arr) {
return {};
}
const criteria = "project";
return arr.reduce(function (acc, currentValue) {
const currentValueElement = currentValue[criteria] || "inbox";
if (!acc[currentValueElement]) {
acc[currentValueElement] = [];
}
acc[currentValueElement].push(currentValue);
return acc;
}, {});
};
const HomePage = () => { const HomePage = () => {
const { todos, loading } = useSelectInboxTodos(); const { data, isLoading } = todosApi.useGetAllTodosQuery();
const groupedTodos = groupByProjects(todos);
if (isLoading) {
return "loading...";
}
const groupedTodos = groupByProjects(data);
const sections = Object.keys(groupedTodos) const sections = Object.keys(groupedTodos)
.filter((s) => s !== "inbox") .filter((s) => s !== "inbox")
.sort(); .sort();
return ( return (
<main className="py-8 px-14 space-y-6"> <main className="py-8 px-14 space-y-6">
<section className="space-y-8 mx-auto max-w-4xl"> <section className="space-y-8 mx-auto max-w-4xl">
{groupedTodos["inbox"] && ( {groupedTodos["inbox"] && (
<div className="space-y-2"> <div className="space-y-2">
<PageHeading title="Inbox" /> <PageHeading title="Inbox" />
<TodoList todos={groupedTodos["inbox"]} hideDone project={""} hideProject={false} /> <TodoList
todos={groupedTodos["inbox"]}
hideDone
project={""}
hideProject={false}
/>
</div> </div>
)} )}
{sections && {sections &&
@ -49,7 +42,7 @@ const HomePage = () => {
/> />
</div> </div>
))} ))}
{!loading && todos && todos.length === 0 && ( {data && data.length === 0 && (
<div className="space-y-4"> <div className="space-y-4">
<PageHeading title="You're done!" /> <PageHeading title="You're done!" />
<AddTodo project={""} /> <AddTodo project={""} />

View File

@ -98,7 +98,11 @@ const TodoDetailsPage = () => {
const router = useRouter(); const router = useRouter();
const { todoId } = router.query as { todoId: string }; const { todoId } = router.query as { todoId: string };
const { todo } = useSelectTodo(todoId); const { todo, isLoading } = useSelectTodo(todoId);
if (isLoading) {
return "loading...";
}
return ( return (
<main className="py-8 px-14 space-y-6"> <main className="py-8 px-14 space-y-6">

View File

@ -1,86 +1,40 @@
import { createContext, FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
import { import {
HubConnection, HubConnection,
HubConnectionBuilder, HubConnectionBuilder,
LogLevel, LogLevel,
} from "@microsoft/signalr"; } from "@microsoft/signalr";
import { asTodo, StatusState, Todo } from "@src/core/entities/todo"; import { getApiBaseUrl } from "@src/infrastructure/apis/utilities";
import { getTodoByIdAsync } from "@src/boundary/todo/todoApi"; import { useAppDispatch } from "@src/infrastructure/store";
import { todosApi } from "@src/infrastructure/apis/todosApi";
interface SocketContextProps {
conn: HubConnection;
todos: Todo[];
inboxTodos: Todo[];
getTodos: () => void;
getInboxTodos: () => void;
createTodo: (todoName: string, project: string, description: string) => void;
updateTodo: (todoId: string, todoStatus: StatusState) => void;
getTodoById(todoId: string): void;
replaceTodo(todo: Todo): void;
}
export const SocketContext = createContext<SocketContextProps>({
conn: void 0,
todos: [],
inboxTodos: [],
getTodos: () => {},
getInboxTodos: () => {},
createTodo: (todoName, project, description) => {},
updateTodo: (todoId, todoStatus) => {},
getTodoById(todoId: string) {},
replaceTodo(todo: Todo) {},
});
export const SocketProvider: FC = (props) => { export const SocketProvider: FC = (props) => {
const [conn, setConn] = useState<HubConnection>(); const [conn, setConn] = useState<HubConnection>();
const [todos, setTodos] = useState<Todo[]>([]); const dispatch = useAppDispatch();
const [inboxTodos, setInboxTodos] = useState<Todo[]>([]);
useEffect(() => { useEffect(() => {
const serverUrl =
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5000";
const connection = new HubConnectionBuilder() const connection = new HubConnectionBuilder()
.withUrl(`${serverUrl}/hubs/todo`, { .withUrl(`${getApiBaseUrl()}/hubs/todo`, {
withCredentials: true, withCredentials: true,
}) })
.withAutomaticReconnect() .withAutomaticReconnect()
.configureLogging(LogLevel.Information) .configureLogging(LogLevel.Information)
.build(); .build();
connection.on("getTodos", (rawTodos) => {
const newTodos = JSON.parse(rawTodos) as Todo[];
const validatedTodos = newTodos.map(asTodo);
setTodos(validatedTodos);
});
connection.on("todoCreated", (todoCreated) => { connection.on("todoCreated", (todoCreated) => {
const { todoId } = JSON.parse(todoCreated) as { todoId: string }; const { todoId } = JSON.parse(todoCreated) as { todoId: string };
if (todoId) {
getTodoByIdAsync(todoId).then((response) => { dispatch(todosApi.endpoints.getTodoById.initiate(todoId));
setTodos([...todos.filter((t) => t.id !== response.id), response]); dispatch(todosApi.util.invalidateTags([{ type: "todo", id: "LIST" }]));
setInboxTodos([ }
...inboxTodos.filter((t) => t.id !== response.id),
response,
]);
});
}); });
connection.on("getInboxTodos", (rawTodos) => { connection.on("todoUpdated", (todoUpdated) => {
const newTodos = JSON.parse(rawTodos) as Todo[]; const { todoId } = JSON.parse(todoUpdated) as { todoId: string };
const validatedTodos = newTodos if (todoId) {
.map(asTodo) dispatch(todosApi.util.invalidateTags([{ type: "todo", id: todoId }]));
.filter((t) => t.status == false); dispatch(todosApi.util.invalidateTags([{ type: "todo", id: "LIST" }]));
setInboxTodos(validatedTodos); }
});
connection.on("getTodo", (todo) => {
const newTodo = JSON.parse(todo) as Todo;
const validatedTodo = asTodo(newTodo);
setTodos([
...todos.filter((t) => t.id !== validatedTodo.id),
validatedTodo,
]);
}); });
connection connection
@ -89,41 +43,13 @@ export const SocketProvider: FC = (props) => {
setConn(connection); setConn(connection);
}) })
.catch((e) => { .catch((e) => {
window.location.href = `${serverUrl}/api/auth/login?returnUrl=${window.location.href}`; window.location.href = `${getApiBaseUrl()}/api/auth/login?returnUrl=${
window.location.href
}`;
}); });
}, []); }, []);
return ( return <>{props.children}</>;
<SocketContext.Provider
value={{
conn,
todos,
inboxTodos,
getTodos: () => {
conn.invoke("GetTodos").catch(console.error);
},
getInboxTodos: () => {
conn.invoke("GetInboxTodos").catch(console.error);
},
createTodo: (todoName, project, description) => {
conn
.invoke("CreateTodo", todoName, project, description)
.catch(console.error);
},
updateTodo: (todoId, todoStatus) => {
conn.invoke("UpdateTodo", todoId, todoStatus).catch(console.error);
},
getTodoById(todoId: string) {
conn.invoke("GetTodo", todoId).catch(console.error);
},
replaceTodo: (todo) => {
conn.invoke("ReplaceTodo", JSON.stringify(todo)).catch(console.error);
},
}}
>
{conn ? props.children : "loading"}
</SocketContext.Provider>
);
}; };
export default SocketProvider; export default SocketProvider;

View File

@ -1,64 +1,51 @@
import { useContext, useEffect } from "react"; import { useEffect } from "react";
import { SocketContext } from "@src/presentation/contexts/SocketContext"; import { useAppSelector } from "@src/infrastructure/store";
import { StatusState, Todo } from "@src/core/entities/todo"; import { todosSelectors } from "@src/infrastructure/state/todo";
import { createTodoAsync } from "@src/boundary/todo/todoApi"; import { todosApi } from "@src/infrastructure/apis/todosApi";
import { Todo } from "@src/core/entities/todo";
export const useSelectInboxTodos = () => { export const useSelectInboxTodos = () => {
const socketContext = useContext(SocketContext); const todos = useAppSelector(todosSelectors.selectAll);
useEffect(() => {
socketContext.getInboxTodos();
}, []);
return { return {
loading: false, loading: false,
todos: socketContext.inboxTodos, todos,
}; };
}; };
export const useCreateTodo = () => { export const useCreateTodo = () => {
const socketContext = useContext(SocketContext); const [createTodo, { isLoading }] = todosApi.useCreateTodoMutation();
return { return {
createTodo: (todoName: string, project: string, description: string) => { createTodo: (todoName: string, project: string, description: string) => {
//socketContext.createTodo(todoName, project, description); createTodo({
createTodoAsync({ title: todoName,
project, project,
description, description,
title: todoName, });
}).catch(console.error);
}, },
}; };
}; };
export const useUpdateTodoState = () => { export const useSelectTodo = (
const socketContext = useContext(SocketContext); todoId: string
): { todo: Todo | undefined; isLoading: boolean } => {
const { data, isLoading } = todosApi.useGetTodoByIdQuery(todoId);
useEffect(() => {}, []);
return { return {
updateTodoState: (todoId: string, todoState: StatusState) => { todo: data,
socketContext.updateTodo(todoId, todoState); isLoading: isLoading,
},
};
};
export const useSelectTodo = (todoId: string): { todo: Todo | undefined } => {
const socketContext = useContext(SocketContext);
useEffect(() => {
socketContext.getTodoById(todoId);
}, []);
return {
todo: socketContext.todos.find((t) => t.id === todoId),
}; };
}; };
export const useUpdateTodo = () => { export const useUpdateTodo = () => {
const socketContext = useContext(SocketContext); const [replaceTodo, { isLoading }] = todosApi.useReplaceTodoMutation();
return { return {
updateTodo: (todo: Todo) => { updateTodo: (todo: Todo) => {
socketContext.replaceTodo(todo); replaceTodo(todo);
}, },
}; };
}; };

View File

@ -875,7 +875,7 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.16.3", "@babel/runtime@^7.8.4": "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
version "7.16.3" version "7.16.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ== integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
@ -1107,6 +1107,16 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590"
integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ== integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==
"@reduxjs/toolkit@^1.6.2":
version "1.6.2"
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.6.2.tgz#2f2b5365df77dd6697da28fdf44f33501ed9ba37"
integrity sha512-HbfI/hOVrAcMGAYsMWxw3UJyIoAS9JTdwddsjlr5w3S50tXhWb+EMyhIw+IAvCVCLETkzdjgH91RjDSYZekVBA==
dependencies:
immer "^9.0.6"
redux "^4.1.0"
redux-thunk "^2.3.0"
reselect "^4.0.0"
"@rollup/plugin-babel@^5.2.0": "@rollup/plugin-babel@^5.2.0":
version "5.3.0" version "5.3.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879"
@ -1182,6 +1192,14 @@
"@types/minimatch" "*" "@types/minimatch" "*"
"@types/node" "*" "@types/node" "*"
"@types/hoist-non-react-statics@^3.3.0":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
dependencies:
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
"@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8": "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8":
version "7.0.9" version "7.0.9"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
@ -1212,6 +1230,25 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11"
integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==
"@types/react-redux@^7.1.20":
version "7.1.20"
resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.20.tgz#42f0e61ababb621e12c66c96dda94c58423bd7df"
integrity sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw==
dependencies:
"@types/hoist-non-react-statics" "^3.3.0"
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
redux "^4.0.0"
"@types/react@*":
version "17.0.35"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.35.tgz#217164cf830267d56cd1aec09dcf25a541eedd4c"
integrity sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/react@^17.0.34": "@types/react@^17.0.34":
version "17.0.34" version "17.0.34"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102"
@ -3060,6 +3097,13 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1" minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
dependencies:
react-is "^16.7.0"
hsl-regex@^1.0.0: hsl-regex@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e"
@ -3132,6 +3176,11 @@ image-size@1.0.0:
dependencies: dependencies:
queue "6.0.2" queue "6.0.2"
immer@^9.0.6:
version "9.0.6"
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73"
integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==
import-cwd@^3.0.0: import-cwd@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-3.0.0.tgz#20845547718015126ea9b3676b7592fb8bd4cf92" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-3.0.0.tgz#20845547718015126ea9b3676b7592fb8bd4cf92"
@ -4574,16 +4623,28 @@ react-dom@17.0.2:
object-assign "^4.1.1" object-assign "^4.1.1"
scheduler "^0.20.2" scheduler "^0.20.2"
react-is@17.0.2: react-is@17.0.2, react-is@^17.0.2:
version "17.0.2" version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-is@^16.8.1: react-is@^16.7.0, react-is@^16.8.1:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-redux@^7.2.6:
version "7.2.6"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.6.tgz#49633a24fe552b5f9caf58feb8a138936ddfe9aa"
integrity sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==
dependencies:
"@babel/runtime" "^7.15.4"
"@types/react-redux" "^7.1.20"
hoist-non-react-statics "^3.3.2"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-is "^17.0.2"
react-refresh@0.8.3: react-refresh@0.8.3:
version "0.8.3" version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
@ -4644,6 +4705,18 @@ reduce-css-calc@^2.1.8:
css-unit-converter "^1.1.1" css-unit-converter "^1.1.1"
postcss-value-parser "^3.3.0" postcss-value-parser "^3.3.0"
redux-thunk@^2.3.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.0.tgz#ac89e1d6b9bdb9ee49ce69a69071be41bbd82d67"
integrity sha512-/y6ZKQNU/0u8Bm7ROLq9Pt/7lU93cT0IucYMrubo89ENjxPa7i8pqLKu6V4X7/TvYovQ6x01unTeyeZ9lgXiTA==
redux@^4.0.0, redux@^4.1.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.2.tgz#140f35426d99bb4729af760afcf79eaaac407104"
integrity sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==
dependencies:
"@babel/runtime" "^7.9.2"
regenerate-unicode-properties@^9.0.0: regenerate-unicode-properties@^9.0.0:
version "9.0.0" version "9.0.0"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-9.0.0.tgz#54d09c7115e1f53dc2314a974b32c1c344efe326" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-9.0.0.tgz#54d09c7115e1f53dc2314a974b32c1c344efe326"
@ -4720,6 +4793,11 @@ requires-port@^1.0.0:
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
reselect@^4.0.0:
version "4.1.4"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.4.tgz#66df0aff41b6ee0f51e2cc17cfaf2c1995916f32"
integrity sha512-i1LgXw8DKSU5qz1EV0ZIKz4yIUHJ7L3bODh+Da6HmVSm9vdL/hG7IpbgzQ3k2XSirzf8/eI7OMEs81gb1VV2fQ==
resolve-from@^4.0.0: resolve-from@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"