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:
|