├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.css
├── App.test.tsx
├── App.tsx
├── Data
│ ├── DataSource
│ │ ├── API
│ │ │ ├── Entity
│ │ │ │ └── TodoAPIEntity.ts
│ │ │ ├── LocalDB
│ │ │ │ └── index.ts
│ │ │ └── TodoAPIDataSource.ts
│ │ └── TodoDataSource.ts
│ └── Repository
│ │ └── TodoRepositoryImpl.ts
├── Domain
│ ├── Model
│ │ └── Todo.ts
│ ├── Repository
│ │ └── TodoRepository.ts
│ └── UseCase
│ │ └── Todo
│ │ ├── CreateTodo.ts
│ │ ├── GetTodos.ts
│ │ ├── RemoveTodo.ts
│ │ └── ToggleCheckTodo.ts
├── Presentation
│ └── Todo
│ │ └── TodoList
│ │ ├── TodoListView.tsx
│ │ └── TodoListViewModel.ts
├── index.css
├── index.tsx
├── logo.svg
├── react-app-env.d.ts
├── reportWebVitals.ts
└── setupTests.ts
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Clean Architecture: Typescript and React
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "clean_ts_react",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@emotion/react": "^11.9.0",
7 | "@emotion/styled": "^11.8.1",
8 | "@mui/material": "^5.6.2",
9 | "@testing-library/jest-dom": "^5.16.4",
10 | "@testing-library/react": "^13.1.1",
11 | "@testing-library/user-event": "^13.5.0",
12 | "@types/jest": "^27.4.1",
13 | "@types/node": "^16.11.27",
14 | "@types/react": "^18.0.5",
15 | "@types/react-dom": "^18.0.1",
16 | "react": "^18.0.0",
17 | "react-dom": "^18.0.0",
18 | "react-scripts": "5.0.1",
19 | "react-toastify": "^9.1.3",
20 | "typescript": "^4.6.3",
21 | "web-vitals": "^2.1.4"
22 | },
23 | "scripts": {
24 | "start": "react-scripts start",
25 | "build": "react-scripts build",
26 | "test": "react-scripts test",
27 | "eject": "react-scripts eject"
28 | },
29 | "eslintConfig": {
30 | "extends": [
31 | "react-app",
32 | "react-app/jest"
33 | ]
34 | },
35 | "browserslist": {
36 | "production": [
37 | ">0.2%",
38 | "not dead",
39 | "not op_mini all"
40 | ],
41 | "development": [
42 | "last 1 chrome version",
43 | "last 1 firefox version",
44 | "last 1 safari version"
45 | ]
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nanosoftonline/clean-typescript-react/0f665d45c4362f642c40a47b484dc62110be01d8/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nanosoftonline/clean-typescript-react/0f665d45c4362f642c40a47b484dc62110be01d8/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nanosoftonline/clean-typescript-react/0f665d45c4362f642c40a47b484dc62110be01d8/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, screen } from "@testing-library/react";
3 | import App from "./App";
4 |
5 | test("renders learn react link", () => {
6 | render();
7 | const linkElement = screen.getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "./App.css";
3 | import TodoListView from "./Presentation/Todo/TodoList/TodoListView";
4 | import { ToastContainer } from "react-toastify";
5 | import "react-toastify/dist/ReactToastify.css";
6 |
7 | function App() {
8 | return (
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
16 | export default App;
17 |
--------------------------------------------------------------------------------
/src/Data/DataSource/API/Entity/TodoAPIEntity.ts:
--------------------------------------------------------------------------------
1 | export interface TodoAPIEntity {
2 | id: string;
3 | title: string;
4 | is_completed: boolean;
5 | }
6 |
--------------------------------------------------------------------------------
/src/Data/DataSource/API/LocalDB/index.ts:
--------------------------------------------------------------------------------
1 | function db(table: string) {
2 | if (window) {
3 | const setTable = (value: any) => window.localStorage.setItem(table, value);
4 | const getTable = () => window.localStorage.getItem(table);
5 |
6 | if (!getTable()) {
7 | setTable(JSON.stringify([]));
8 | }
9 |
10 | const allLocal = () => JSON.parse(getTable()!);
11 |
12 | return {
13 | getAll() {
14 | return allLocal() as T[];
15 | },
16 |
17 | getById(columnId: string) {
18 | const item = (this.getAll() as any[]).find(
19 | (i: { id: string }) => i.id === columnId
20 | )!;
21 | return item as T;
22 | },
23 |
24 | removeById(columnId: string) {
25 | const idx = (this.getAll() as any[]).findIndex(
26 | (i: { id: string }) => i.id === columnId
27 | )!;
28 |
29 | if (idx > -1) {
30 | setTable(
31 | JSON.stringify([
32 | ...(allLocal() as any[]).filter((i) => i.id !== columnId),
33 | ])
34 | );
35 | return true;
36 | }
37 | return false;
38 | },
39 |
40 | create(params: T) {
41 | const stringified = JSON.stringify([...allLocal(), params]);
42 | setTable(stringified);
43 | },
44 |
45 | updateByField(columnId: string, field: any, newValue: any) {
46 | const item = (this.getAll() as any[]).find(
47 | (i: { id: string }) => i.id === columnId
48 | ) as T;
49 | let newItem = { ...item, [field]: newValue } as T;
50 |
51 | if (newValue === "toggle") {
52 | newItem = { ...item, [field]: !item[field as keyof T] };
53 | }
54 |
55 | const stringified = JSON.stringify([
56 | ...(allLocal() as { id: string }[]).map((i) => {
57 | const isChanged = i.id === columnId;
58 | return {
59 | ...i,
60 | ...(isChanged ? newItem : {}),
61 | };
62 | }),
63 | ]);
64 | setTable(stringified);
65 | return newItem;
66 | },
67 | };
68 | }
69 |
70 | return {
71 | getAll() {
72 | return [] as T[];
73 | },
74 | getById(columnId: string) {
75 | return {} as T;
76 | },
77 | removeById(columnId: string) {
78 | return false;
79 | },
80 | create(params: T) {},
81 | // update(id: string, newModel: Todo): Promise
82 | updateByField(columnId: string, field: any, newValue: any) {
83 | return {} as T;
84 | },
85 | };
86 | }
87 |
88 | export default db;
89 |
--------------------------------------------------------------------------------
/src/Data/DataSource/API/TodoAPIDataSource.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "../../../Domain/Model/Todo";
2 | import TodoDataSource from "../TodoDataSource";
3 | import { TodoAPIEntity } from "./Entity/TodoAPIEntity";
4 | import localDB from "./LocalDB";
5 |
6 | export default class TodoAPIDataSourceImpl implements TodoDataSource {
7 | db = localDB("todos");
8 | async createTodo(value: string) {
9 | const res: Todo = {
10 | id: new Date().getSeconds().toString(),
11 | isComplete: false,
12 | title: value,
13 | };
14 |
15 | this.db.create({
16 | id: res.id,
17 | is_completed: res.isComplete,
18 | title: res.title,
19 | });
20 | return res;
21 | }
22 |
23 | async getTodos(): Promise {
24 | const data = this.db?.getAll();
25 |
26 | return data?.map((item) => ({
27 | id: item.id,
28 | title: item.title,
29 | isComplete: item.is_completed,
30 | }));
31 | }
32 |
33 | async toggleTodoCheck(id: string) {
34 | const item = this.db.updateByField(id, "is_completed", "toggle");
35 | return item.is_completed;
36 | }
37 |
38 | async removeTodo(id: string) {
39 | return this.db.removeById(id);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Data/DataSource/TodoDataSource.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "../../Domain/Model/Todo";
2 |
3 | export default interface TodoDataSource {
4 | getTodos(): Promise;
5 | createTodo(value: string): Promise;
6 | toggleTodoCheck(id: string): Promise;
7 | removeTodo(id: string): Promise;
8 | }
9 |
--------------------------------------------------------------------------------
/src/Data/Repository/TodoRepositoryImpl.ts:
--------------------------------------------------------------------------------
1 | import { TodoRepository } from "../../Domain/Repository/TodoRepository";
2 | import TodoDataSource from "../DataSource/TodoDataSource";
3 |
4 | export class TodoRepositoryImpl implements TodoRepository {
5 | dataSource: TodoDataSource;
6 |
7 | constructor(_datasource: TodoDataSource) {
8 | this.dataSource = _datasource;
9 | }
10 |
11 | async createTodo(value: string) {
12 | return this.dataSource.createTodo(value);
13 | }
14 |
15 | async getTodos() {
16 | return this.dataSource.getTodos();
17 | }
18 |
19 | async markAsRead(id: string) {
20 | return this.dataSource.toggleTodoCheck(id);
21 | }
22 |
23 | async removeTodo(id: string) {
24 | return this.dataSource.removeTodo(id);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Domain/Model/Todo.ts:
--------------------------------------------------------------------------------
1 | export interface Todo {
2 | id: string;
3 | title: string;
4 | isComplete: boolean;
5 | }
6 |
--------------------------------------------------------------------------------
/src/Domain/Repository/TodoRepository.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "../Model/Todo";
2 |
3 | export interface TodoRepository {
4 | getTodos(): Promise;
5 | createTodo(value: string): Promise;
6 | markAsRead(id: string): Promise;
7 | removeTodo(id: string): Promise;
8 | }
9 |
--------------------------------------------------------------------------------
/src/Domain/UseCase/Todo/CreateTodo.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "../../Model/Todo";
2 | import { TodoRepository } from "../../Repository/TodoRepository";
3 |
4 | export interface CreateTodosUseCase {
5 | invoke: (value: string) => Promise;
6 | }
7 |
8 | export class CreateTodo implements CreateTodosUseCase {
9 | private todoRepo: TodoRepository;
10 | constructor(_todoRepo: TodoRepository) {
11 | this.todoRepo = _todoRepo;
12 | }
13 |
14 | async invoke(value: string) {
15 | if (value.length < 2) {
16 | throw new Error(
17 | "Your todo should have at leat 2 characters."
18 | );
19 | }
20 | const created = this.todoRepo.createTodo(value);
21 | return created;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Domain/UseCase/Todo/GetTodos.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "../../Model/Todo";
2 | import { TodoRepository } from "../../Repository/TodoRepository";
3 |
4 | export interface GetTodosUseCase {
5 | invoke: () => Promise;
6 | }
7 |
8 | export class GetTodos implements GetTodosUseCase {
9 | private todoRepo: TodoRepository;
10 | constructor(_todoRepo: TodoRepository) {
11 | this.todoRepo = _todoRepo;
12 | }
13 |
14 | async invoke() {
15 | return this.todoRepo.getTodos();
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Domain/UseCase/Todo/RemoveTodo.ts:
--------------------------------------------------------------------------------
1 | import { TodoRepository } from "../../Repository/TodoRepository";
2 |
3 | export interface RemoveTodosUseCase {
4 | invoke: (id: string) => Promise;
5 | }
6 |
7 | export class RemoveTodo implements RemoveTodosUseCase {
8 | private todoRepo: TodoRepository;
9 | constructor(_todoRepo: TodoRepository) {
10 | this.todoRepo = _todoRepo;
11 | }
12 |
13 | async invoke(id: string) {
14 | return this.todoRepo.removeTodo(id);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Domain/UseCase/Todo/ToggleCheckTodo.ts:
--------------------------------------------------------------------------------
1 | import { TodoRepository } from "../../Repository/TodoRepository";
2 |
3 | export interface ToggleCheckTodoUseCase {
4 | invoke: (id: string) => Promise;
5 | }
6 |
7 | export class ToggleCheckTodo implements ToggleCheckTodoUseCase {
8 | private todoRepo: TodoRepository;
9 | constructor(_todoRepo: TodoRepository) {
10 | this.todoRepo = _todoRepo;
11 | }
12 |
13 | async invoke(id: string) {
14 | return this.todoRepo.markAsRead(id);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Presentation/Todo/TodoList/TodoListView.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import useViewModel from "./TodoListViewModel";
3 | import {
4 | List,
5 | ListItem,
6 | ListItemIcon,
7 | Checkbox,
8 | ListItemText,
9 | } from "@mui/material";
10 |
11 | export default function TodoListView() {
12 | const {
13 | getTodos,
14 | createTodo,
15 | onChangeValue,
16 | toggleRead,
17 | removeTodo,
18 | value,
19 | todos,
20 | } = useViewModel();
21 |
22 | useEffect(() => {
23 | getTodos();
24 | }, []);
25 |
26 | return (
27 |
28 |
34 |
35 | {todos.map((todo, i) => {
36 | return (
37 |
38 |
39 | toggleRead(todo.id)}
42 | />
43 |
44 |
45 |
46 |
47 | );
48 | })}
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/Presentation/Todo/TodoList/TodoListViewModel.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import TodoAPIDataSourceImpl from "../../../Data/DataSource/API/TodoAPIDataSource";
3 | import { TodoRepositoryImpl } from "../../../Data/Repository/TodoRepositoryImpl";
4 | import { Todo } from "../../../Domain/Model/Todo";
5 | import { GetTodos } from "../../../Domain/UseCase/Todo/GetTodos";
6 | import { CreateTodo } from "../../../Domain/UseCase/Todo/CreateTodo";
7 | import { ToggleCheckTodo } from "../../../Domain/UseCase/Todo/ToggleCheckTodo";
8 | import { RemoveTodo } from "../../../Domain/UseCase/Todo/RemoveTodo";
9 | import { toast } from "react-toastify";
10 |
11 | export default function TodoListViewModel() {
12 | const [todos, setTodos] = useState([]);
13 | const [value, setValue] = useState("");
14 |
15 | const todosDataSourceImpl = new TodoAPIDataSourceImpl();
16 | const todosRepositoryImpl = new TodoRepositoryImpl(todosDataSourceImpl);
17 |
18 | const getTodosUseCase = new GetTodos(todosRepositoryImpl);
19 | const createTodosUseCase = new CreateTodo(todosRepositoryImpl);
20 | const toggleCheckTodoUseCase = new ToggleCheckTodo(todosRepositoryImpl);
21 | const removeTodosUseCase = new RemoveTodo(todosRepositoryImpl);
22 |
23 | function _resetValue() {
24 | setValue("");
25 | }
26 |
27 | async function getTodos() {
28 | setTodos(await getTodosUseCase.invoke());
29 | }
30 |
31 | async function createTodo() {
32 | try {
33 | const createdTodo = await createTodosUseCase.invoke(value);
34 | setTodos((prev) => [...prev, createdTodo]);
35 | _resetValue();
36 | } catch (e) {
37 | _resetValue();
38 | if (e instanceof Error) {
39 | toast(e.message);
40 | }
41 | }
42 | }
43 |
44 | async function toggleRead(id: string) {
45 | const createdTodo = await toggleCheckTodoUseCase.invoke(id);
46 | setTodos((prev) => [
47 | ...prev.map((i) => {
48 | const isToggled = i.id === id;
49 |
50 | return {
51 | ...i,
52 | isComplete: isToggled ? createdTodo : i.isComplete,
53 | };
54 | }),
55 | ]);
56 | }
57 |
58 | async function removeTodo(id: string) {
59 | const isRemoved = await removeTodosUseCase.invoke(id);
60 | if (isRemoved) {
61 | setTodos((prev) => {
62 | return [...prev.filter((i) => i.id !== id)];
63 | });
64 | }
65 | }
66 |
67 | function onChangeValue(e: React.ChangeEvent) {
68 | e.preventDefault();
69 | setValue(e.target.value);
70 | }
71 |
72 | return {
73 | getTodos,
74 | onChangeValue,
75 | createTodo,
76 | toggleRead,
77 | removeTodo,
78 | todos,
79 | value,
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import "./index.css";
4 | import App from "./App";
5 | import reportWebVitals from "./reportWebVitals";
6 |
7 | const root = ReactDOM.createRoot(
8 | document.getElementById("root") as HTMLElement
9 | );
10 | root.render(
11 |
12 |
13 |
14 | );
15 |
16 | // If you want to start measuring performance in your app, pass a function
17 | // to log results (for example: reportWebVitals(console.log))
18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
19 | reportWebVitals();
20 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from "web-vitals";
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import "@testing-library/jest-dom";
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx",
22 | "experimentalDecorators": true
23 | },
24 | "include": [
25 | "src"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------