Upload 36 files
Browse files- .dockerignore +1 -0
- .gitignore +130 -0
- Dockerfile +92 -0
- backend/package.json +33 -0
- backend/spec/persistence/sqlite.spec.js +65 -0
- backend/spec/routes/addItem.spec.js +30 -0
- backend/spec/routes/deleteItem.spec.js +20 -0
- backend/spec/routes/getItems.spec.js +19 -0
- backend/spec/routes/updateItem.spec.js +33 -0
- backend/src/index.js +36 -0
- backend/src/persistence/index.js +2 -0
- backend/src/persistence/mysql.js +135 -0
- backend/src/persistence/sqlite.js +113 -0
- backend/src/routes/addItem.js +13 -0
- backend/src/routes/deleteItem.js +6 -0
- backend/src/routes/getGreeting.js +7 -0
- backend/src/routes/getItems.js +6 -0
- backend/src/routes/updateItem.js +10 -0
- backend/src/static/.gitkeep +0 -0
- backend/yarn.lock +0 -0
- client/.eslintrc.cjs +20 -0
- client/.gitignore +24 -0
- client/index.html +17 -0
- client/package.json +43 -0
- client/public/vite.svg +1 -0
- client/src/App.jsx +20 -0
- client/src/components/AddNewItemForm.jsx +55 -0
- client/src/components/Greeting.jsx +15 -0
- client/src/components/ItemDisplay.jsx +86 -0
- client/src/components/ItemDisplay.scss +33 -0
- client/src/components/TodoListCard.jsx +59 -0
- client/src/index.scss +7 -0
- client/src/main.jsx +10 -0
- client/vite.config.js +7 -0
- client/yarn.lock +0 -0
- compose.yml +176 -0
.dockerignore
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
node_modules
|
.gitignore
ADDED
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Logs
|
2 |
+
logs
|
3 |
+
*.log
|
4 |
+
npm-debug.log*
|
5 |
+
yarn-debug.log*
|
6 |
+
yarn-error.log*
|
7 |
+
lerna-debug.log*
|
8 |
+
.pnpm-debug.log*
|
9 |
+
|
10 |
+
# Diagnostic reports (https://nodejs.org/api/report.html)
|
11 |
+
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
12 |
+
|
13 |
+
# Runtime data
|
14 |
+
pids
|
15 |
+
*.pid
|
16 |
+
*.seed
|
17 |
+
*.pid.lock
|
18 |
+
|
19 |
+
# Directory for instrumented libs generated by jscoverage/JSCover
|
20 |
+
lib-cov
|
21 |
+
|
22 |
+
# Coverage directory used by tools like istanbul
|
23 |
+
coverage
|
24 |
+
*.lcov
|
25 |
+
|
26 |
+
# nyc test coverage
|
27 |
+
.nyc_output
|
28 |
+
|
29 |
+
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
30 |
+
.grunt
|
31 |
+
|
32 |
+
# Bower dependency directory (https://bower.io/)
|
33 |
+
bower_components
|
34 |
+
|
35 |
+
# node-waf configuration
|
36 |
+
.lock-wscript
|
37 |
+
|
38 |
+
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
39 |
+
build/Release
|
40 |
+
|
41 |
+
# Dependency directories
|
42 |
+
node_modules/
|
43 |
+
jspm_packages/
|
44 |
+
|
45 |
+
# Snowpack dependency directory (https://snowpack.dev/)
|
46 |
+
web_modules/
|
47 |
+
|
48 |
+
# TypeScript cache
|
49 |
+
*.tsbuildinfo
|
50 |
+
|
51 |
+
# Optional npm cache directory
|
52 |
+
.npm
|
53 |
+
|
54 |
+
# Optional eslint cache
|
55 |
+
.eslintcache
|
56 |
+
|
57 |
+
# Optional stylelint cache
|
58 |
+
.stylelintcache
|
59 |
+
|
60 |
+
# Microbundle cache
|
61 |
+
.rpt2_cache/
|
62 |
+
.rts2_cache_cjs/
|
63 |
+
.rts2_cache_es/
|
64 |
+
.rts2_cache_umd/
|
65 |
+
|
66 |
+
# Optional REPL history
|
67 |
+
.node_repl_history
|
68 |
+
|
69 |
+
# Output of 'npm pack'
|
70 |
+
*.tgz
|
71 |
+
|
72 |
+
# Yarn Integrity file
|
73 |
+
.yarn-integrity
|
74 |
+
|
75 |
+
# dotenv environment variable files
|
76 |
+
.env
|
77 |
+
.env.development.local
|
78 |
+
.env.test.local
|
79 |
+
.env.production.local
|
80 |
+
.env.local
|
81 |
+
|
82 |
+
# parcel-bundler cache (https://parceljs.org/)
|
83 |
+
.cache
|
84 |
+
.parcel-cache
|
85 |
+
|
86 |
+
# Next.js build output
|
87 |
+
.next
|
88 |
+
out
|
89 |
+
|
90 |
+
# Nuxt.js build / generate output
|
91 |
+
.nuxt
|
92 |
+
dist
|
93 |
+
|
94 |
+
# Gatsby files
|
95 |
+
.cache/
|
96 |
+
# Comment in the public line in if your project uses Gatsby and not Next.js
|
97 |
+
# https://nextjs.org/blog/next-9-1#public-directory-support
|
98 |
+
# public
|
99 |
+
|
100 |
+
# vuepress build output
|
101 |
+
.vuepress/dist
|
102 |
+
|
103 |
+
# vuepress v2.x temp and cache directory
|
104 |
+
.temp
|
105 |
+
.cache
|
106 |
+
|
107 |
+
# Docusaurus cache and generated files
|
108 |
+
.docusaurus
|
109 |
+
|
110 |
+
# Serverless directories
|
111 |
+
.serverless/
|
112 |
+
|
113 |
+
# FuseBox cache
|
114 |
+
.fusebox/
|
115 |
+
|
116 |
+
# DynamoDB Local files
|
117 |
+
.dynamodb/
|
118 |
+
|
119 |
+
# TernJS port file
|
120 |
+
.tern-port
|
121 |
+
|
122 |
+
# Stores VSCode versions used for testing VSCode extensions
|
123 |
+
.vscode-test
|
124 |
+
|
125 |
+
# yarn v2
|
126 |
+
.yarn/cache
|
127 |
+
.yarn/unplugged
|
128 |
+
.yarn/build-state.yml
|
129 |
+
.yarn/install-state.gz
|
130 |
+
.pnp.*
|
Dockerfile
ADDED
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
###################################################
|
2 |
+
# Stage: base
|
3 |
+
#
|
4 |
+
# This base stage ensures all other stages are using the same base image
|
5 |
+
# and provides common configuration for all stages, such as the working dir.
|
6 |
+
###################################################
|
7 |
+
FROM node:20 AS base
|
8 |
+
WORKDIR /usr/local/app
|
9 |
+
|
10 |
+
################## CLIENT STAGES ##################
|
11 |
+
|
12 |
+
###################################################
|
13 |
+
# Stage: client-base
|
14 |
+
#
|
15 |
+
# This stage is used as the base for the client-dev and client-build stages,
|
16 |
+
# since there are common steps needed for each.
|
17 |
+
###################################################
|
18 |
+
FROM base AS client-base
|
19 |
+
COPY client/package.json client/yarn.lock ./
|
20 |
+
RUN --mount=type=cache,id=yarn,target=/usr/local/share/.cache/yarn \
|
21 |
+
yarn install
|
22 |
+
COPY client/.eslintrc.cjs client/index.html client/vite.config.js ./
|
23 |
+
COPY client/public ./public
|
24 |
+
COPY client/src ./src
|
25 |
+
|
26 |
+
###################################################
|
27 |
+
# Stage: client-dev
|
28 |
+
#
|
29 |
+
# This stage is used for development of the client application. It sets
|
30 |
+
# the default command to start the Vite development server.
|
31 |
+
###################################################
|
32 |
+
FROM client-base AS client-dev
|
33 |
+
CMD ["yarn", "dev"]
|
34 |
+
|
35 |
+
###################################################
|
36 |
+
# Stage: client-build
|
37 |
+
#
|
38 |
+
# This stage builds the client application, producing static HTML, CSS, and
|
39 |
+
# JS files that can be served by the backend.
|
40 |
+
###################################################
|
41 |
+
FROM client-base AS client-build
|
42 |
+
RUN yarn build
|
43 |
+
|
44 |
+
|
45 |
+
|
46 |
+
|
47 |
+
###################################################
|
48 |
+
################ BACKEND STAGES #################
|
49 |
+
###################################################
|
50 |
+
|
51 |
+
###################################################
|
52 |
+
# Stage: backend-base
|
53 |
+
#
|
54 |
+
# This stage is used as the base for the backend-dev and test stages, since
|
55 |
+
# there are common steps needed for each.
|
56 |
+
###################################################
|
57 |
+
FROM base AS backend-dev
|
58 |
+
COPY backend/package.json backend/yarn.lock ./
|
59 |
+
RUN --mount=type=cache,id=yarn,target=/usr/local/share/.cache/yarn \
|
60 |
+
yarn install --frozen-lockfile
|
61 |
+
COPY backend/spec ./spec
|
62 |
+
COPY backend/src ./src
|
63 |
+
CMD ["yarn", "dev"]
|
64 |
+
|
65 |
+
###################################################
|
66 |
+
# Stage: test
|
67 |
+
#
|
68 |
+
# This stage runs the tests on the backend. This is split into a separate
|
69 |
+
# stage to allow the final image to not have the test dependencies or test
|
70 |
+
# cases.
|
71 |
+
###################################################
|
72 |
+
FROM backend-dev AS test
|
73 |
+
RUN yarn test
|
74 |
+
|
75 |
+
###################################################
|
76 |
+
# Stage: final
|
77 |
+
#
|
78 |
+
# This stage is intended to be the final "production" image. It sets up the
|
79 |
+
# backend and copies the built client application from the client-build stage.
|
80 |
+
#
|
81 |
+
# It pulls the package.json and yarn.lock from the test stage to ensure that
|
82 |
+
# the tests run (without this, the test stage would simply be skipped).
|
83 |
+
###################################################
|
84 |
+
FROM base AS final
|
85 |
+
ENV NODE_ENV=production
|
86 |
+
COPY --from=test /usr/local/app/package.json /usr/local/app/yarn.lock ./
|
87 |
+
RUN --mount=type=cache,id=yarn,target=/usr/local/share/.cache/yarn \
|
88 |
+
yarn install --production --frozen-lockfile
|
89 |
+
COPY backend/src ./src
|
90 |
+
COPY --from=client-build /usr/local/app/dist ./src/static
|
91 |
+
EXPOSE 3000
|
92 |
+
CMD ["node", "src/index.js"]
|
backend/package.json
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "backend",
|
3 |
+
"version": "1.0.0",
|
4 |
+
"main": "index.js",
|
5 |
+
"scripts": {
|
6 |
+
"format": "prettier -l --write \"**/*.js\"",
|
7 |
+
"format-check": "prettier --check \"**/*.js\"",
|
8 |
+
"test": "jest",
|
9 |
+
"dev": "nodemon src/index.js"
|
10 |
+
},
|
11 |
+
"dependencies": {
|
12 |
+
"express": "^4.18.2",
|
13 |
+
"mysql2": "^3.9.1",
|
14 |
+
"sqlite3": "^5.1.7",
|
15 |
+
"uuid": "^9.0.1",
|
16 |
+
"wait-port": "^1.1.0"
|
17 |
+
},
|
18 |
+
"resolutions": {
|
19 |
+
"@babel/core": "7.23.9"
|
20 |
+
},
|
21 |
+
"prettier": {
|
22 |
+
"trailingComma": "all",
|
23 |
+
"tabWidth": 4,
|
24 |
+
"useTabs": false,
|
25 |
+
"semi": true,
|
26 |
+
"singleQuote": true
|
27 |
+
},
|
28 |
+
"devDependencies": {
|
29 |
+
"jest": "^29.7.0",
|
30 |
+
"nodemon": "^3.0.3",
|
31 |
+
"prettier": "^3.2.4"
|
32 |
+
}
|
33 |
+
}
|
backend/spec/persistence/sqlite.spec.js
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const db = require('../../src/persistence/sqlite');
|
2 |
+
const fs = require('fs');
|
3 |
+
const location = process.env.SQLITE_DB_LOCATION || '/etc/todos/todo.db';
|
4 |
+
|
5 |
+
const ITEM = {
|
6 |
+
id: '7aef3d7c-d301-4846-8358-2a91ec9d6be3',
|
7 |
+
name: 'Test',
|
8 |
+
completed: false,
|
9 |
+
};
|
10 |
+
|
11 |
+
beforeEach(() => {
|
12 |
+
if (fs.existsSync(location)) {
|
13 |
+
fs.unlinkSync(location);
|
14 |
+
}
|
15 |
+
});
|
16 |
+
|
17 |
+
test('it initializes correctly', async () => {
|
18 |
+
await db.init();
|
19 |
+
});
|
20 |
+
|
21 |
+
test('it can store and retrieve items', async () => {
|
22 |
+
await db.init();
|
23 |
+
|
24 |
+
await db.storeItem(ITEM);
|
25 |
+
|
26 |
+
const items = await db.getItems();
|
27 |
+
expect(items.length).toBe(1);
|
28 |
+
expect(items[0]).toEqual(ITEM);
|
29 |
+
});
|
30 |
+
|
31 |
+
test('it can update an existing item', async () => {
|
32 |
+
await db.init();
|
33 |
+
|
34 |
+
const initialItems = await db.getItems();
|
35 |
+
expect(initialItems.length).toBe(0);
|
36 |
+
|
37 |
+
await db.storeItem(ITEM);
|
38 |
+
|
39 |
+
await db.updateItem(
|
40 |
+
ITEM.id,
|
41 |
+
Object.assign({}, ITEM, { completed: !ITEM.completed }),
|
42 |
+
);
|
43 |
+
|
44 |
+
const items = await db.getItems();
|
45 |
+
expect(items.length).toBe(1);
|
46 |
+
expect(items[0].completed).toBe(!ITEM.completed);
|
47 |
+
});
|
48 |
+
|
49 |
+
test('it can remove an existing item', async () => {
|
50 |
+
await db.init();
|
51 |
+
await db.storeItem(ITEM);
|
52 |
+
|
53 |
+
await db.removeItem(ITEM.id);
|
54 |
+
|
55 |
+
const items = await db.getItems();
|
56 |
+
expect(items.length).toBe(0);
|
57 |
+
});
|
58 |
+
|
59 |
+
test('it can get a single item', async () => {
|
60 |
+
await db.init();
|
61 |
+
await db.storeItem(ITEM);
|
62 |
+
|
63 |
+
const item = await db.getItem(ITEM.id);
|
64 |
+
expect(item).toEqual(ITEM);
|
65 |
+
});
|
backend/spec/routes/addItem.spec.js
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const db = require('../../src/persistence');
|
2 |
+
const addItem = require('../../src/routes/addItem');
|
3 |
+
const ITEM = { id: 12345 };
|
4 |
+
const { v4: uuid } = require('uuid');
|
5 |
+
|
6 |
+
jest.mock('uuid', () => ({ v4: jest.fn() }));
|
7 |
+
|
8 |
+
jest.mock('../../src/persistence', () => ({
|
9 |
+
removeItem: jest.fn(),
|
10 |
+
storeItem: jest.fn(),
|
11 |
+
getItem: jest.fn(),
|
12 |
+
}));
|
13 |
+
|
14 |
+
test('it stores item correctly', async () => {
|
15 |
+
const id = 'something-not-a-uuid';
|
16 |
+
const name = 'A sample item';
|
17 |
+
const req = { body: { name } };
|
18 |
+
const res = { send: jest.fn() };
|
19 |
+
|
20 |
+
uuid.mockReturnValue(id);
|
21 |
+
|
22 |
+
await addItem(req, res);
|
23 |
+
|
24 |
+
const expectedItem = { id, name, completed: false };
|
25 |
+
|
26 |
+
expect(db.storeItem.mock.calls.length).toBe(1);
|
27 |
+
expect(db.storeItem.mock.calls[0][0]).toEqual(expectedItem);
|
28 |
+
expect(res.send.mock.calls[0].length).toBe(1);
|
29 |
+
expect(res.send.mock.calls[0][0]).toEqual(expectedItem);
|
30 |
+
});
|
backend/spec/routes/deleteItem.spec.js
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const db = require('../../src/persistence');
|
2 |
+
const deleteItem = require('../../src/routes/deleteItem');
|
3 |
+
const ITEM = { id: 12345 };
|
4 |
+
|
5 |
+
jest.mock('../../src/persistence', () => ({
|
6 |
+
removeItem: jest.fn(),
|
7 |
+
getItem: jest.fn(),
|
8 |
+
}));
|
9 |
+
|
10 |
+
test('it removes item correctly', async () => {
|
11 |
+
const req = { params: { id: 12345 } };
|
12 |
+
const res = { sendStatus: jest.fn() };
|
13 |
+
|
14 |
+
await deleteItem(req, res);
|
15 |
+
|
16 |
+
expect(db.removeItem.mock.calls.length).toBe(1);
|
17 |
+
expect(db.removeItem.mock.calls[0][0]).toBe(req.params.id);
|
18 |
+
expect(res.sendStatus.mock.calls[0].length).toBe(1);
|
19 |
+
expect(res.sendStatus.mock.calls[0][0]).toBe(200);
|
20 |
+
});
|
backend/spec/routes/getItems.spec.js
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const db = require('../../src/persistence');
|
2 |
+
const getItems = require('../../src/routes/getItems');
|
3 |
+
const ITEMS = [{ id: 12345 }];
|
4 |
+
|
5 |
+
jest.mock('../../src/persistence', () => ({
|
6 |
+
getItems: jest.fn(),
|
7 |
+
}));
|
8 |
+
|
9 |
+
test('it gets items correctly', async () => {
|
10 |
+
const req = {};
|
11 |
+
const res = { send: jest.fn() };
|
12 |
+
db.getItems.mockReturnValue(Promise.resolve(ITEMS));
|
13 |
+
|
14 |
+
await getItems(req, res);
|
15 |
+
|
16 |
+
expect(db.getItems.mock.calls.length).toBe(1);
|
17 |
+
expect(res.send.mock.calls[0].length).toBe(1);
|
18 |
+
expect(res.send.mock.calls[0][0]).toEqual(ITEMS);
|
19 |
+
});
|
backend/spec/routes/updateItem.spec.js
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const db = require('../../src/persistence');
|
2 |
+
const updateItem = require('../../src/routes/updateItem');
|
3 |
+
const ITEM = { id: 12345 };
|
4 |
+
|
5 |
+
jest.mock('../../src/persistence', () => ({
|
6 |
+
getItem: jest.fn(),
|
7 |
+
updateItem: jest.fn(),
|
8 |
+
}));
|
9 |
+
|
10 |
+
test('it updates items correctly', async () => {
|
11 |
+
const req = {
|
12 |
+
params: { id: 1234 },
|
13 |
+
body: { name: 'New title', completed: false },
|
14 |
+
};
|
15 |
+
const res = { send: jest.fn() };
|
16 |
+
|
17 |
+
db.getItem.mockReturnValue(Promise.resolve(ITEM));
|
18 |
+
|
19 |
+
await updateItem(req, res);
|
20 |
+
|
21 |
+
expect(db.updateItem.mock.calls.length).toBe(1);
|
22 |
+
expect(db.updateItem.mock.calls[0][0]).toBe(req.params.id);
|
23 |
+
expect(db.updateItem.mock.calls[0][1]).toEqual({
|
24 |
+
name: 'New title',
|
25 |
+
completed: false,
|
26 |
+
});
|
27 |
+
|
28 |
+
expect(db.getItem.mock.calls.length).toBe(1);
|
29 |
+
expect(db.getItem.mock.calls[0][0]).toBe(req.params.id);
|
30 |
+
|
31 |
+
expect(res.send.mock.calls[0].length).toBe(1);
|
32 |
+
expect(res.send.mock.calls[0][0]).toEqual(ITEM);
|
33 |
+
});
|
backend/src/index.js
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const app = express();
|
3 |
+
const db = require('./persistence');
|
4 |
+
const getGreeting = require('./routes/getGreeting');
|
5 |
+
const getItems = require('./routes/getItems');
|
6 |
+
const addItem = require('./routes/addItem');
|
7 |
+
const updateItem = require('./routes/updateItem');
|
8 |
+
const deleteItem = require('./routes/deleteItem');
|
9 |
+
|
10 |
+
app.use(express.json());
|
11 |
+
app.use(express.static(__dirname + '/static'));
|
12 |
+
|
13 |
+
app.get('/api/greeting', getGreeting);
|
14 |
+
app.get('/api/items', getItems);
|
15 |
+
app.post('/api/items', addItem);
|
16 |
+
app.put('/api/items/:id', updateItem);
|
17 |
+
app.delete('/api/items/:id', deleteItem);
|
18 |
+
|
19 |
+
db.init()
|
20 |
+
.then(() => {
|
21 |
+
app.listen(3000, () => console.log('Listening on port 3000'));
|
22 |
+
})
|
23 |
+
.catch((err) => {
|
24 |
+
console.error(err);
|
25 |
+
process.exit(1);
|
26 |
+
});
|
27 |
+
|
28 |
+
const gracefulShutdown = () => {
|
29 |
+
db.teardown()
|
30 |
+
.catch(() => {})
|
31 |
+
.then(() => process.exit());
|
32 |
+
};
|
33 |
+
|
34 |
+
process.on('SIGINT', gracefulShutdown);
|
35 |
+
process.on('SIGTERM', gracefulShutdown);
|
36 |
+
process.on('SIGUSR2', gracefulShutdown); // Sent by nodemon
|
backend/src/persistence/index.js
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
if (process.env.MYSQL_HOST) module.exports = require('./mysql');
|
2 |
+
else module.exports = require('./sqlite');
|
backend/src/persistence/mysql.js
ADDED
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const waitPort = require('wait-port');
|
2 |
+
const fs = require('fs');
|
3 |
+
const mysql = require('mysql2');
|
4 |
+
|
5 |
+
const {
|
6 |
+
MYSQL_HOST: HOST,
|
7 |
+
MYSQL_HOST_FILE: HOST_FILE,
|
8 |
+
MYSQL_USER: USER,
|
9 |
+
MYSQL_USER_FILE: USER_FILE,
|
10 |
+
MYSQL_PASSWORD: PASSWORD,
|
11 |
+
MYSQL_PASSWORD_FILE: PASSWORD_FILE,
|
12 |
+
MYSQL_DB: DB,
|
13 |
+
MYSQL_DB_FILE: DB_FILE,
|
14 |
+
} = process.env;
|
15 |
+
|
16 |
+
let pool;
|
17 |
+
|
18 |
+
async function init() {
|
19 |
+
const host = HOST_FILE ? fs.readFileSync(HOST_FILE) : HOST;
|
20 |
+
const user = USER_FILE ? fs.readFileSync(USER_FILE) : USER;
|
21 |
+
const password = PASSWORD_FILE ? fs.readFileSync(PASSWORD_FILE) : PASSWORD;
|
22 |
+
const database = DB_FILE ? fs.readFileSync(DB_FILE) : DB;
|
23 |
+
|
24 |
+
await waitPort({
|
25 |
+
host,
|
26 |
+
port: 3306,
|
27 |
+
timeout: 10000,
|
28 |
+
waitForDns: true,
|
29 |
+
});
|
30 |
+
|
31 |
+
pool = mysql.createPool({
|
32 |
+
connectionLimit: 5,
|
33 |
+
host,
|
34 |
+
user,
|
35 |
+
password,
|
36 |
+
database,
|
37 |
+
charset: 'utf8mb4',
|
38 |
+
});
|
39 |
+
|
40 |
+
return new Promise((acc, rej) => {
|
41 |
+
pool.query(
|
42 |
+
'CREATE TABLE IF NOT EXISTS todo_items (id varchar(36), name varchar(255), completed boolean) DEFAULT CHARSET utf8mb4',
|
43 |
+
(err) => {
|
44 |
+
if (err) return rej(err);
|
45 |
+
|
46 |
+
console.log(`Connected to mysql db at host ${HOST}`);
|
47 |
+
acc();
|
48 |
+
},
|
49 |
+
);
|
50 |
+
});
|
51 |
+
}
|
52 |
+
|
53 |
+
async function teardown() {
|
54 |
+
return new Promise((acc, rej) => {
|
55 |
+
pool.end((err) => {
|
56 |
+
if (err) rej(err);
|
57 |
+
else acc();
|
58 |
+
});
|
59 |
+
});
|
60 |
+
}
|
61 |
+
|
62 |
+
async function getItems() {
|
63 |
+
return new Promise((acc, rej) => {
|
64 |
+
pool.query('SELECT * FROM todo_items', (err, rows) => {
|
65 |
+
if (err) return rej(err);
|
66 |
+
acc(
|
67 |
+
rows.map((item) =>
|
68 |
+
Object.assign({}, item, {
|
69 |
+
completed: item.completed === 1,
|
70 |
+
}),
|
71 |
+
),
|
72 |
+
);
|
73 |
+
});
|
74 |
+
});
|
75 |
+
}
|
76 |
+
|
77 |
+
async function getItem(id) {
|
78 |
+
return new Promise((acc, rej) => {
|
79 |
+
pool.query('SELECT * FROM todo_items WHERE id=?', [id], (err, rows) => {
|
80 |
+
if (err) return rej(err);
|
81 |
+
acc(
|
82 |
+
rows.map((item) =>
|
83 |
+
Object.assign({}, item, {
|
84 |
+
completed: item.completed === 1,
|
85 |
+
}),
|
86 |
+
)[0],
|
87 |
+
);
|
88 |
+
});
|
89 |
+
});
|
90 |
+
}
|
91 |
+
|
92 |
+
async function storeItem(item) {
|
93 |
+
return new Promise((acc, rej) => {
|
94 |
+
pool.query(
|
95 |
+
'INSERT INTO todo_items (id, name, completed) VALUES (?, ?, ?)',
|
96 |
+
[item.id, item.name, item.completed ? 1 : 0],
|
97 |
+
(err) => {
|
98 |
+
if (err) return rej(err);
|
99 |
+
acc();
|
100 |
+
},
|
101 |
+
);
|
102 |
+
});
|
103 |
+
}
|
104 |
+
|
105 |
+
async function updateItem(id, item) {
|
106 |
+
return new Promise((acc, rej) => {
|
107 |
+
pool.query(
|
108 |
+
'UPDATE todo_items SET name=?, completed=? WHERE id=?',
|
109 |
+
[item.name, item.completed ? 1 : 0, id],
|
110 |
+
(err) => {
|
111 |
+
if (err) return rej(err);
|
112 |
+
acc();
|
113 |
+
},
|
114 |
+
);
|
115 |
+
});
|
116 |
+
}
|
117 |
+
|
118 |
+
async function removeItem(id) {
|
119 |
+
return new Promise((acc, rej) => {
|
120 |
+
pool.query('DELETE FROM todo_items WHERE id = ?', [id], (err) => {
|
121 |
+
if (err) return rej(err);
|
122 |
+
acc();
|
123 |
+
});
|
124 |
+
});
|
125 |
+
}
|
126 |
+
|
127 |
+
module.exports = {
|
128 |
+
init,
|
129 |
+
teardown,
|
130 |
+
getItems,
|
131 |
+
getItem,
|
132 |
+
storeItem,
|
133 |
+
updateItem,
|
134 |
+
removeItem,
|
135 |
+
};
|
backend/src/persistence/sqlite.js
ADDED
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const sqlite3 = require('sqlite3').verbose();
|
2 |
+
const fs = require('fs');
|
3 |
+
const location = process.env.SQLITE_DB_LOCATION || '/etc/todos/todo.db';
|
4 |
+
|
5 |
+
let db, dbAll, dbRun;
|
6 |
+
|
7 |
+
function init() {
|
8 |
+
const dirName = require('path').dirname(location);
|
9 |
+
if (!fs.existsSync(dirName)) {
|
10 |
+
fs.mkdirSync(dirName, { recursive: true });
|
11 |
+
}
|
12 |
+
|
13 |
+
return new Promise((acc, rej) => {
|
14 |
+
db = new sqlite3.Database(location, (err) => {
|
15 |
+
if (err) return rej(err);
|
16 |
+
|
17 |
+
if (process.env.NODE_ENV !== 'test')
|
18 |
+
console.log(`Using sqlite database at ${location}`);
|
19 |
+
|
20 |
+
db.run(
|
21 |
+
'CREATE TABLE IF NOT EXISTS todo_items (id varchar(36), name varchar(255), completed boolean)',
|
22 |
+
(err, result) => {
|
23 |
+
if (err) return rej(err);
|
24 |
+
acc();
|
25 |
+
},
|
26 |
+
);
|
27 |
+
});
|
28 |
+
});
|
29 |
+
}
|
30 |
+
|
31 |
+
async function teardown() {
|
32 |
+
return new Promise((acc, rej) => {
|
33 |
+
db.close((err) => {
|
34 |
+
if (err) rej(err);
|
35 |
+
else acc();
|
36 |
+
});
|
37 |
+
});
|
38 |
+
}
|
39 |
+
|
40 |
+
async function getItems() {
|
41 |
+
return new Promise((acc, rej) => {
|
42 |
+
db.all('SELECT * FROM todo_items', (err, rows) => {
|
43 |
+
if (err) return rej(err);
|
44 |
+
acc(
|
45 |
+
rows.map((item) =>
|
46 |
+
Object.assign({}, item, {
|
47 |
+
completed: item.completed === 1,
|
48 |
+
}),
|
49 |
+
),
|
50 |
+
);
|
51 |
+
});
|
52 |
+
});
|
53 |
+
}
|
54 |
+
|
55 |
+
async function getItem(id) {
|
56 |
+
return new Promise((acc, rej) => {
|
57 |
+
db.all('SELECT * FROM todo_items WHERE id=?', [id], (err, rows) => {
|
58 |
+
if (err) return rej(err);
|
59 |
+
acc(
|
60 |
+
rows.map((item) =>
|
61 |
+
Object.assign({}, item, {
|
62 |
+
completed: item.completed === 1,
|
63 |
+
}),
|
64 |
+
)[0],
|
65 |
+
);
|
66 |
+
});
|
67 |
+
});
|
68 |
+
}
|
69 |
+
|
70 |
+
async function storeItem(item) {
|
71 |
+
return new Promise((acc, rej) => {
|
72 |
+
db.run(
|
73 |
+
'INSERT INTO todo_items (id, name, completed) VALUES (?, ?, ?)',
|
74 |
+
[item.id, item.name, item.completed ? 1 : 0],
|
75 |
+
(err) => {
|
76 |
+
if (err) return rej(err);
|
77 |
+
acc();
|
78 |
+
},
|
79 |
+
);
|
80 |
+
});
|
81 |
+
}
|
82 |
+
|
83 |
+
async function updateItem(id, item) {
|
84 |
+
return new Promise((acc, rej) => {
|
85 |
+
db.run(
|
86 |
+
'UPDATE todo_items SET name=?, completed=? WHERE id = ?',
|
87 |
+
[item.name, item.completed ? 1 : 0, id],
|
88 |
+
(err) => {
|
89 |
+
if (err) return rej(err);
|
90 |
+
acc();
|
91 |
+
},
|
92 |
+
);
|
93 |
+
});
|
94 |
+
}
|
95 |
+
|
96 |
+
async function removeItem(id) {
|
97 |
+
return new Promise((acc, rej) => {
|
98 |
+
db.run('DELETE FROM todo_items WHERE id = ?', [id], (err) => {
|
99 |
+
if (err) return rej(err);
|
100 |
+
acc();
|
101 |
+
});
|
102 |
+
});
|
103 |
+
}
|
104 |
+
|
105 |
+
module.exports = {
|
106 |
+
init,
|
107 |
+
teardown,
|
108 |
+
getItems,
|
109 |
+
getItem,
|
110 |
+
storeItem,
|
111 |
+
updateItem,
|
112 |
+
removeItem,
|
113 |
+
};
|
backend/src/routes/addItem.js
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const db = require('../persistence');
|
2 |
+
const { v4: uuid } = require('uuid');
|
3 |
+
|
4 |
+
module.exports = async (req, res) => {
|
5 |
+
const item = {
|
6 |
+
id: uuid(),
|
7 |
+
name: req.body.name,
|
8 |
+
completed: false,
|
9 |
+
};
|
10 |
+
|
11 |
+
await db.storeItem(item);
|
12 |
+
res.send(item);
|
13 |
+
};
|
backend/src/routes/deleteItem.js
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const db = require('../persistence');
|
2 |
+
|
3 |
+
module.exports = async (req, res) => {
|
4 |
+
await db.removeItem(req.params.id);
|
5 |
+
res.sendStatus(200);
|
6 |
+
};
|
backend/src/routes/getGreeting.js
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const GREETING = 'Hello world!';
|
2 |
+
|
3 |
+
module.exports = async (req, res) => {
|
4 |
+
res.send({
|
5 |
+
greeting: GREETING,
|
6 |
+
});
|
7 |
+
};
|
backend/src/routes/getItems.js
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const db = require('../persistence');
|
2 |
+
|
3 |
+
module.exports = async (req, res) => {
|
4 |
+
const items = await db.getItems();
|
5 |
+
res.send(items);
|
6 |
+
};
|
backend/src/routes/updateItem.js
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const db = require('../persistence');
|
2 |
+
|
3 |
+
module.exports = async (req, res) => {
|
4 |
+
await db.updateItem(req.params.id, {
|
5 |
+
name: req.body.name,
|
6 |
+
completed: req.body.completed,
|
7 |
+
});
|
8 |
+
const item = await db.getItem(req.params.id);
|
9 |
+
res.send(item);
|
10 |
+
};
|
backend/src/static/.gitkeep
ADDED
File without changes
|
backend/yarn.lock
ADDED
The diff for this file is too large to render.
See raw diff
|
|
client/.eslintrc.cjs
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
module.exports = {
|
2 |
+
root: true,
|
3 |
+
env: { browser: true, es2020: true },
|
4 |
+
extends: [
|
5 |
+
'eslint:recommended',
|
6 |
+
'plugin:react/recommended',
|
7 |
+
'plugin:react/jsx-runtime',
|
8 |
+
'plugin:react-hooks/recommended',
|
9 |
+
],
|
10 |
+
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
11 |
+
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
12 |
+
settings: { react: { version: '18.2' } },
|
13 |
+
plugins: ['react-refresh'],
|
14 |
+
rules: {
|
15 |
+
'react-refresh/only-export-components': [
|
16 |
+
'warn',
|
17 |
+
{ allowConstantExport: true },
|
18 |
+
],
|
19 |
+
},
|
20 |
+
};
|
client/.gitignore
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Logs
|
2 |
+
logs
|
3 |
+
*.log
|
4 |
+
npm-debug.log*
|
5 |
+
yarn-debug.log*
|
6 |
+
yarn-error.log*
|
7 |
+
pnpm-debug.log*
|
8 |
+
lerna-debug.log*
|
9 |
+
|
10 |
+
node_modules
|
11 |
+
dist
|
12 |
+
dist-ssr
|
13 |
+
*.local
|
14 |
+
|
15 |
+
# Editor directories and files
|
16 |
+
.vscode/*
|
17 |
+
!.vscode/extensions.json
|
18 |
+
.idea
|
19 |
+
.DS_Store
|
20 |
+
*.suo
|
21 |
+
*.ntvs*
|
22 |
+
*.njsproj
|
23 |
+
*.sln
|
24 |
+
*.sw?
|
client/index.html
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<link
|
8 |
+
href="https://fonts.googleapis.com/css?family=Lato&display=swap"
|
9 |
+
rel="stylesheet"
|
10 |
+
/>
|
11 |
+
<title>Todo App</title>
|
12 |
+
</head>
|
13 |
+
<body>
|
14 |
+
<div id="root"></div>
|
15 |
+
<script type="module" src="/src/main.jsx"></script>
|
16 |
+
</body>
|
17 |
+
</html>
|
client/package.json
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "client",
|
3 |
+
"private": true,
|
4 |
+
"version": "0.0.0",
|
5 |
+
"type": "module",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "vite --host=0.0.0.0",
|
8 |
+
"build": "vite build",
|
9 |
+
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
10 |
+
"preview": "vite preview",
|
11 |
+
"format": "prettier --write \"**/*.jsx\"",
|
12 |
+
"format-check": "prettier --check \"**/*.js\""
|
13 |
+
},
|
14 |
+
"dependencies": {
|
15 |
+
"@fortawesome/fontawesome-free-regular": "^5.0.13",
|
16 |
+
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
17 |
+
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
18 |
+
"@fortawesome/react-fontawesome": "^0.2.0",
|
19 |
+
"bootstrap": "^5.3.2",
|
20 |
+
"react": "^18.2.0",
|
21 |
+
"react-bootstrap": "^2.10.0",
|
22 |
+
"react-dom": "^18.2.0",
|
23 |
+
"sass": "^1.70.0"
|
24 |
+
},
|
25 |
+
"devDependencies": {
|
26 |
+
"@types/react": "^18.2.43",
|
27 |
+
"@types/react-dom": "^18.2.17",
|
28 |
+
"@vitejs/plugin-react": "^4.2.1",
|
29 |
+
"eslint": "^8.55.0",
|
30 |
+
"eslint-plugin-react": "^7.33.2",
|
31 |
+
"eslint-plugin-react-hooks": "^4.6.0",
|
32 |
+
"eslint-plugin-react-refresh": "^0.4.5",
|
33 |
+
"prettier": "^3.2.4",
|
34 |
+
"vite": "^5.0.8"
|
35 |
+
},
|
36 |
+
"prettier": {
|
37 |
+
"trailingComma": "all",
|
38 |
+
"tabWidth": 4,
|
39 |
+
"useTabs": false,
|
40 |
+
"semi": true,
|
41 |
+
"singleQuote": true
|
42 |
+
}
|
43 |
+
}
|
client/public/vite.svg
ADDED
|
client/src/App.jsx
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import Col from 'react-bootstrap/Col';
|
2 |
+
import Container from 'react-bootstrap/Container';
|
3 |
+
import Row from 'react-bootstrap/Row';
|
4 |
+
import { TodoListCard } from './components/TodoListCard';
|
5 |
+
import { Greeting } from './components/Greeting';
|
6 |
+
|
7 |
+
function App() {
|
8 |
+
return (
|
9 |
+
<Container>
|
10 |
+
<Row>
|
11 |
+
<Col md={{ offset: 3, span: 6 }}>
|
12 |
+
<Greeting />
|
13 |
+
<TodoListCard />
|
14 |
+
</Col>
|
15 |
+
</Row>
|
16 |
+
</Container>
|
17 |
+
);
|
18 |
+
}
|
19 |
+
|
20 |
+
export default App;
|
client/src/components/AddNewItemForm.jsx
ADDED
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } from 'react';
|
2 |
+
import PropTypes from 'prop-types';
|
3 |
+
import Button from 'react-bootstrap/Button';
|
4 |
+
import Form from 'react-bootstrap/Form';
|
5 |
+
import InputGroup from 'react-bootstrap/InputGroup';
|
6 |
+
|
7 |
+
export function AddItemForm({ onNewItem }) {
|
8 |
+
const [newItem, setNewItem] = useState('');
|
9 |
+
const [submitting, setSubmitting] = useState(false);
|
10 |
+
|
11 |
+
const submitNewItem = (e) => {
|
12 |
+
e.preventDefault();
|
13 |
+
setSubmitting(true);
|
14 |
+
|
15 |
+
const options = {
|
16 |
+
method: 'POST',
|
17 |
+
body: JSON.stringify({ name: newItem }),
|
18 |
+
headers: { 'Content-Type': 'application/json' },
|
19 |
+
};
|
20 |
+
|
21 |
+
fetch('/api/items', options)
|
22 |
+
.then((r) => r.json())
|
23 |
+
.then((item) => {
|
24 |
+
onNewItem(item);
|
25 |
+
setSubmitting(false);
|
26 |
+
setNewItem('');
|
27 |
+
});
|
28 |
+
};
|
29 |
+
|
30 |
+
return (
|
31 |
+
<Form onSubmit={submitNewItem}>
|
32 |
+
<InputGroup className="mb-3">
|
33 |
+
<Form.Control
|
34 |
+
value={newItem}
|
35 |
+
onChange={(e) => setNewItem(e.target.value)}
|
36 |
+
type="text"
|
37 |
+
placeholder="New Item"
|
38 |
+
aria-label="New item"
|
39 |
+
/>
|
40 |
+
<Button
|
41 |
+
type="submit"
|
42 |
+
variant="success"
|
43 |
+
disabled={!newItem.length}
|
44 |
+
className={submitting ? 'disabled' : ''}
|
45 |
+
>
|
46 |
+
{submitting ? 'Adding...' : 'Add Item'}
|
47 |
+
</Button>
|
48 |
+
</InputGroup>
|
49 |
+
</Form>
|
50 |
+
);
|
51 |
+
}
|
52 |
+
|
53 |
+
AddItemForm.propTypes = {
|
54 |
+
onNewItem: PropTypes.func,
|
55 |
+
};
|
client/src/components/Greeting.jsx
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect, useState } from 'react';
|
2 |
+
|
3 |
+
export function Greeting() {
|
4 |
+
const [greeting, setGreeting] = useState(null);
|
5 |
+
|
6 |
+
useEffect(() => {
|
7 |
+
fetch('/api/greeting')
|
8 |
+
.then((res) => res.json())
|
9 |
+
.then((data) => setGreeting(data.greeting));
|
10 |
+
}, [setGreeting]);
|
11 |
+
|
12 |
+
if (!greeting) return null;
|
13 |
+
|
14 |
+
return <h1 className="text-center mb-5">{greeting}</h1>;
|
15 |
+
}
|
client/src/components/ItemDisplay.jsx
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import PropTypes from 'prop-types';
|
2 |
+
import Container from 'react-bootstrap/Container';
|
3 |
+
import Row from 'react-bootstrap/Row';
|
4 |
+
import Col from 'react-bootstrap/Col';
|
5 |
+
import Button from 'react-bootstrap/Button';
|
6 |
+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
7 |
+
import { faTrash } from '@fortawesome/free-solid-svg-icons/faTrash';
|
8 |
+
import faCheckSquare from '@fortawesome/fontawesome-free-regular/faCheckSquare';
|
9 |
+
import faSquare from '@fortawesome/fontawesome-free-regular/faSquare';
|
10 |
+
import './ItemDisplay.scss';
|
11 |
+
|
12 |
+
export function ItemDisplay({ item, onItemUpdate, onItemRemoval }) {
|
13 |
+
const toggleCompletion = () => {
|
14 |
+
fetch(`/api/items/${item.id}`, {
|
15 |
+
method: 'PUT',
|
16 |
+
body: JSON.stringify({
|
17 |
+
name: item.name,
|
18 |
+
completed: !item.completed,
|
19 |
+
}),
|
20 |
+
headers: { 'Content-Type': 'application/json' },
|
21 |
+
})
|
22 |
+
.then((r) => r.json())
|
23 |
+
.then(onItemUpdate);
|
24 |
+
};
|
25 |
+
|
26 |
+
const removeItem = () => {
|
27 |
+
fetch(`/api/items/${item.id}`, { method: 'DELETE' }).then(() =>
|
28 |
+
onItemRemoval(item),
|
29 |
+
);
|
30 |
+
};
|
31 |
+
|
32 |
+
return (
|
33 |
+
<Container fluid className={`item ${item.completed && 'completed'}`}>
|
34 |
+
<Row>
|
35 |
+
<Col xs={2} className="text-center">
|
36 |
+
<Button
|
37 |
+
className="toggles"
|
38 |
+
size="sm"
|
39 |
+
variant="link"
|
40 |
+
onClick={toggleCompletion}
|
41 |
+
aria-label={
|
42 |
+
item.completed
|
43 |
+
? 'Mark item as incomplete'
|
44 |
+
: 'Mark item as complete'
|
45 |
+
}
|
46 |
+
>
|
47 |
+
<FontAwesomeIcon
|
48 |
+
icon={item.completed ? faCheckSquare : faSquare}
|
49 |
+
/>
|
50 |
+
<i
|
51 |
+
className={`far ${
|
52 |
+
item.completed ? 'fa-check-square' : 'fa-square'
|
53 |
+
}`}
|
54 |
+
/>
|
55 |
+
</Button>
|
56 |
+
</Col>
|
57 |
+
<Col xs={8} className="name">
|
58 |
+
{item.name}
|
59 |
+
</Col>
|
60 |
+
<Col xs={2} className="text-center remove">
|
61 |
+
<Button
|
62 |
+
size="sm"
|
63 |
+
variant="link"
|
64 |
+
onClick={removeItem}
|
65 |
+
aria-label="Remove Item"
|
66 |
+
>
|
67 |
+
<FontAwesomeIcon
|
68 |
+
icon={faTrash}
|
69 |
+
className="text-danger"
|
70 |
+
/>
|
71 |
+
</Button>
|
72 |
+
</Col>
|
73 |
+
</Row>
|
74 |
+
</Container>
|
75 |
+
);
|
76 |
+
}
|
77 |
+
|
78 |
+
ItemDisplay.propTypes = {
|
79 |
+
item: PropTypes.shape({
|
80 |
+
id: PropTypes.string,
|
81 |
+
name: PropTypes.string,
|
82 |
+
completed: PropTypes.bool,
|
83 |
+
}),
|
84 |
+
onItemUpdate: PropTypes.func,
|
85 |
+
onItemRemoval: PropTypes.func,
|
86 |
+
};
|
client/src/components/ItemDisplay.scss
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.item {
|
2 |
+
background-color: white;
|
3 |
+
padding: 15px;
|
4 |
+
margin-bottom: 15px;
|
5 |
+
border: transparent;
|
6 |
+
border-radius: 5px;
|
7 |
+
box-shadow: 0 0 1em #ccc;
|
8 |
+
transition: all 0.2s ease-in-out;
|
9 |
+
|
10 |
+
&:hover {
|
11 |
+
box-shadow: 0 0 1em #aaa;
|
12 |
+
}
|
13 |
+
|
14 |
+
&.completed {
|
15 |
+
text-decoration: line-through;
|
16 |
+
}
|
17 |
+
}
|
18 |
+
|
19 |
+
.toggles {
|
20 |
+
color: black;
|
21 |
+
}
|
22 |
+
|
23 |
+
.name {
|
24 |
+
padding-top: 3px;
|
25 |
+
}
|
26 |
+
|
27 |
+
.remove {
|
28 |
+
padding-left: 0;
|
29 |
+
}
|
30 |
+
|
31 |
+
button:focus {
|
32 |
+
border: 1px solid #333;
|
33 |
+
}
|
client/src/components/TodoListCard.jsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useCallback, useEffect, useState } from 'react';
|
2 |
+
import { AddItemForm } from './AddNewItemForm';
|
3 |
+
import { ItemDisplay } from './ItemDisplay';
|
4 |
+
|
5 |
+
export function TodoListCard() {
|
6 |
+
const [items, setItems] = useState(null);
|
7 |
+
|
8 |
+
useEffect(() => {
|
9 |
+
fetch('/api/items')
|
10 |
+
.then((r) => r.json())
|
11 |
+
.then(setItems);
|
12 |
+
}, []);
|
13 |
+
|
14 |
+
const onNewItem = useCallback(
|
15 |
+
(newItem) => {
|
16 |
+
setItems([...items, newItem]);
|
17 |
+
},
|
18 |
+
[items],
|
19 |
+
);
|
20 |
+
|
21 |
+
const onItemUpdate = useCallback(
|
22 |
+
(item) => {
|
23 |
+
const index = items.findIndex((i) => i.id === item.id);
|
24 |
+
setItems([
|
25 |
+
...items.slice(0, index),
|
26 |
+
item,
|
27 |
+
...items.slice(index + 1),
|
28 |
+
]);
|
29 |
+
},
|
30 |
+
[items],
|
31 |
+
);
|
32 |
+
|
33 |
+
const onItemRemoval = useCallback(
|
34 |
+
(item) => {
|
35 |
+
const index = items.findIndex((i) => i.id === item.id);
|
36 |
+
setItems([...items.slice(0, index), ...items.slice(index + 1)]);
|
37 |
+
},
|
38 |
+
[items],
|
39 |
+
);
|
40 |
+
|
41 |
+
if (items === null) return 'Loading...';
|
42 |
+
|
43 |
+
return (
|
44 |
+
<>
|
45 |
+
<AddItemForm onNewItem={onNewItem} />
|
46 |
+
{items.length === 0 && (
|
47 |
+
<p className="text-center">No items yet! Add one above!</p>
|
48 |
+
)}
|
49 |
+
{items.map((item) => (
|
50 |
+
<ItemDisplay
|
51 |
+
key={item.id}
|
52 |
+
item={item}
|
53 |
+
onItemUpdate={onItemUpdate}
|
54 |
+
onItemRemoval={onItemRemoval}
|
55 |
+
/>
|
56 |
+
))}
|
57 |
+
</>
|
58 |
+
);
|
59 |
+
}
|
client/src/index.scss
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@import 'bootstrap/scss/bootstrap';
|
2 |
+
|
3 |
+
body {
|
4 |
+
background-color: #f4f4f4;
|
5 |
+
margin-top: 50px;
|
6 |
+
font-family: 'Lato';
|
7 |
+
}
|
client/src/main.jsx
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import ReactDOM from 'react-dom/client';
|
3 |
+
import App from './App.jsx';
|
4 |
+
import './index.scss';
|
5 |
+
|
6 |
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
7 |
+
<React.StrictMode>
|
8 |
+
<App />
|
9 |
+
</React.StrictMode>,
|
10 |
+
);
|
client/vite.config.js
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { defineConfig } from 'vite';
|
2 |
+
import react from '@vitejs/plugin-react';
|
3 |
+
|
4 |
+
// https://vitejs.dev/config/
|
5 |
+
export default defineConfig({
|
6 |
+
plugins: [react()],
|
7 |
+
});
|
client/yarn.lock
ADDED
The diff for this file is too large to render.
See raw diff
|
|
compose.yml
ADDED
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
###################################################
|
2 |
+
# This Compose file provides the development environment for the todo app.
|
3 |
+
#
|
4 |
+
# Seeing the final version of the application bundles the frontend with the
|
5 |
+
# backend, we are able to "simulate" that by using a proxy to route requests
|
6 |
+
# to the appropriate service. All requests to /api will be routed to the
|
7 |
+
# backend while all other requests will be sent to the client service. While
|
8 |
+
# there is some overlap in the routing rules, the proxy determines the service
|
9 |
+
# based on the most specific rule.
|
10 |
+
#
|
11 |
+
# To support easier debugging and troubleshooting, phpMyAdmin is also included
|
12 |
+
# to provide a web interface to the MySQL database.
|
13 |
+
###################################################
|
14 |
+
|
15 |
+
###################################################
|
16 |
+
# Services
|
17 |
+
#
|
18 |
+
# The services define the individual components of our application stack.
|
19 |
+
# For each service, a separate container will be launched.
|
20 |
+
###################################################
|
21 |
+
services:
|
22 |
+
|
23 |
+
###################################################
|
24 |
+
# Service: proxy
|
25 |
+
#
|
26 |
+
# This service is a reverse proxy that will route requests to the appropriate
|
27 |
+
# service. Think of it like a HTTP router or a load balancer. It simply
|
28 |
+
# forwards requests and allows us to simulate the final version of the
|
29 |
+
# application where the frontend and backend are bundled together. We can
|
30 |
+
# also use it to route requests to phpMyAdmin, which won't be accessible at
|
31 |
+
# localhost, but at db.localhost.
|
32 |
+
#
|
33 |
+
# The image for this service comes directly from Docker Hub and is a Docker
|
34 |
+
# Official Image. Since Traefik can be configured in a variety of ways, we
|
35 |
+
# configure it here to watch the Docker events for new containers and to use
|
36 |
+
# their labels for configuration. That's why the Docker socket is mounted.
|
37 |
+
#
|
38 |
+
# We also expose port 80 to connect to the proxy from the host machine.
|
39 |
+
###################################################
|
40 |
+
proxy:
|
41 |
+
image: traefik:v2.11
|
42 |
+
command: --providers.docker
|
43 |
+
ports:
|
44 |
+
- 80:80
|
45 |
+
volumes:
|
46 |
+
- /var/run/docker.sock:/var/run/docker.sock
|
47 |
+
|
48 |
+
###################################################
|
49 |
+
# Service: backend
|
50 |
+
#
|
51 |
+
# This service is the Node.js server that provides the API for the app.
|
52 |
+
# When the container starts, it will use the image that results
|
53 |
+
# from building the Dockerfile, targeting the backend-dev stage.
|
54 |
+
#
|
55 |
+
# The Compose Watch configuration is used to automatically sync the code
|
56 |
+
# from the host machine to the container. This allows the server to be
|
57 |
+
# automatically reloaded when code changes are made.
|
58 |
+
#
|
59 |
+
# The environment variables configure the application to connect to the
|
60 |
+
# database, which is also configured in this Compose file. We obviously
|
61 |
+
# wouldn't hard-code these values in a production environment. But, in
|
62 |
+
# dev, these values are fine.
|
63 |
+
#
|
64 |
+
# Finally, the labels are used to configure Traefik (the reverse proxy) with
|
65 |
+
# the appropriate routing rules. In this case, all requests to localhost/api/*
|
66 |
+
# will be forwarded to this service's port 3000.
|
67 |
+
###################################################
|
68 |
+
backend:
|
69 |
+
build:
|
70 |
+
context: ./
|
71 |
+
target: backend-dev
|
72 |
+
environment:
|
73 |
+
MYSQL_HOST: mysql
|
74 |
+
MYSQL_USER: root
|
75 |
+
MYSQL_PASSWORD: secret
|
76 |
+
MYSQL_DB: todos
|
77 |
+
develop:
|
78 |
+
watch:
|
79 |
+
- path: ./backend/src
|
80 |
+
action: sync
|
81 |
+
target: /usr/local/app/src
|
82 |
+
- path: ./backend/package.json
|
83 |
+
action: rebuild
|
84 |
+
labels:
|
85 |
+
traefik.http.routers.backend.rule: Host(`localhost`) && PathPrefix(`/api`)
|
86 |
+
traefik.http.services.backend.loadbalancer.server.port: 3000
|
87 |
+
|
88 |
+
###################################################
|
89 |
+
# Service: client
|
90 |
+
#
|
91 |
+
# The client service is the React app that provides the frontend for the app.
|
92 |
+
# When the container starts, it will use the image that results from building
|
93 |
+
# the Dockerfile, targeting the dev stage.
|
94 |
+
#
|
95 |
+
# The Compose Watch configuration is used to automatically sync the code from
|
96 |
+
# the host machine to the container. This allows the client to be automatically
|
97 |
+
# reloaded when code changes are made.
|
98 |
+
#
|
99 |
+
# The labels are used to configure Traefik (the reverse proxy) with the
|
100 |
+
# appropriate routing rules. In this case, all requests to localhost will be
|
101 |
+
# forwarded to this service's port 5173.
|
102 |
+
###################################################
|
103 |
+
client:
|
104 |
+
build:
|
105 |
+
context: ./
|
106 |
+
target: client-dev
|
107 |
+
develop:
|
108 |
+
watch:
|
109 |
+
- path: ./client/src
|
110 |
+
action: sync
|
111 |
+
target: /usr/local/app/src
|
112 |
+
- path: ./client/package.json
|
113 |
+
action: rebuild
|
114 |
+
labels:
|
115 |
+
traefik.http.routers.client.rule: Host(`localhost`)
|
116 |
+
traefik.http.services.client.loadbalancer.server.port: 5173
|
117 |
+
|
118 |
+
|
119 |
+
###################################################
|
120 |
+
# Service: mysql
|
121 |
+
#
|
122 |
+
# The MySQL service is used to provide the database for the application.
|
123 |
+
# The image for this service comes directly from Docker Hub and is a Docker
|
124 |
+
# Official Image.
|
125 |
+
|
126 |
+
# The data is persisted in a volume named todo-mysql-data. Using a volume
|
127 |
+
# allows us to take down the services without losing the data. When we start
|
128 |
+
# the services again, the data will still be there (assuming we didn't delete
|
129 |
+
# the volume, of course!).
|
130 |
+
#
|
131 |
+
# The environment variables configure the root password and the name of the
|
132 |
+
# database to create. Since these are used only for local development, it's
|
133 |
+
# ok to hard-code them here.
|
134 |
+
###################################################
|
135 |
+
mysql:
|
136 |
+
image: mysql:8.0
|
137 |
+
volumes:
|
138 |
+
- todo-mysql-data:/var/lib/mysql
|
139 |
+
environment:
|
140 |
+
MYSQL_ROOT_PASSWORD: secret
|
141 |
+
MYSQL_DATABASE: todos
|
142 |
+
|
143 |
+
###################################################
|
144 |
+
# Service: phpmyadmin
|
145 |
+
#
|
146 |
+
# This service provides a web interface to the MySQL database. It's useful
|
147 |
+
# for debugging and troubleshooting data, schemas, and more. The image for
|
148 |
+
# this service comes directly from Docker Hub and is a Docker Official Image.
|
149 |
+
#
|
150 |
+
# The environment variables configure the connection to the database and
|
151 |
+
# provide the default credentials, letting us immediately open the interface
|
152 |
+
# without needing to log in.
|
153 |
+
#
|
154 |
+
# The labels are used to configure Traefik (the reverse proxy) with the
|
155 |
+
# routing rules. In this case, all requests to db.localhost will be forwarded
|
156 |
+
# to this service's port 80.
|
157 |
+
###################################################
|
158 |
+
phpmyadmin:
|
159 |
+
image: phpmyadmin
|
160 |
+
environment:
|
161 |
+
PMA_HOST: mysql
|
162 |
+
PMA_USER: root
|
163 |
+
PMA_PASSWORD: secret
|
164 |
+
labels:
|
165 |
+
traefik.http.routers.phpmyadmin.rule: Host(`db.localhost`)
|
166 |
+
traefik.http.services.phpmyadmin.loadbalancer.server.port: 80
|
167 |
+
|
168 |
+
###################################################
|
169 |
+
# Volumes
|
170 |
+
#
|
171 |
+
# For this application stack, we only have one volume. It's used to persist the
|
172 |
+
# data for the MySQL service. We are only going to use the default values,
|
173 |
+
# hence the lack of any configuration for the volume.
|
174 |
+
###################################################
|
175 |
+
volumes:
|
176 |
+
todo-mysql-data:
|