├── .gitignore ├── Justfile ├── README.md ├── backend ├── .dockerignore ├── Dockerfile ├── Dockerfile.production ├── README.md ├── pyproject.toml ├── requirements-dev.ini ├── requirements-dev.txt ├── scripts │ ├── odm_comparison.py │ └── sample_data.py ├── src │ └── todo │ │ ├── __main__.py │ │ ├── cli.py │ │ ├── dal_beanie.py │ │ ├── dal_motor.py │ │ └── server.py └── tests │ ├── conftest.py │ ├── test_dal_beanie.py │ └── test_dal_motor.py ├── compose.yml ├── farm-stack.code-workspace ├── frontend ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.png │ ├── index.html │ ├── manifest.json │ └── robots.txt └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── ListTodoLists.css │ ├── ListTodoLists.js │ ├── ToDoList.css │ ├── ToDoList.js │ ├── index.css │ ├── index.js │ ├── reportWebVitals.js │ └── setupTests.js └── nginx └── nginx.conf /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,node,osx 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,node,osx 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | ### OSX ### 145 | # General 146 | .DS_Store 147 | .AppleDouble 148 | .LSOverride 149 | 150 | # Icon must end with two \r 151 | Icon 152 | 153 | # Thumbnails 154 | ._* 155 | 156 | # Files that might appear in the root of a volume 157 | .DocumentRevisions-V100 158 | .fseventsd 159 | .Spotlight-V100 160 | .TemporaryItems 161 | .Trashes 162 | .VolumeIcon.icns 163 | .com.apple.timemachine.donotpresent 164 | 165 | # Directories potentially created on remote AFP share 166 | .AppleDB 167 | .AppleDesktop 168 | Network Trash Folder 169 | Temporary Items 170 | .apdisk 171 | 172 | ### Python ### 173 | # Byte-compiled / optimized / DLL files 174 | __pycache__/ 175 | *.py[cod] 176 | *$py.class 177 | 178 | # C extensions 179 | *.so 180 | 181 | # Distribution / packaging 182 | .Python 183 | build/ 184 | develop-eggs/ 185 | dist/ 186 | downloads/ 187 | eggs/ 188 | .eggs/ 189 | lib/ 190 | lib64/ 191 | parts/ 192 | sdist/ 193 | var/ 194 | wheels/ 195 | share/python-wheels/ 196 | *.egg-info/ 197 | .installed.cfg 198 | *.egg 199 | MANIFEST 200 | 201 | # PyInstaller 202 | # Usually these files are written by a python script from a template 203 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 204 | *.manifest 205 | *.spec 206 | 207 | # Installer logs 208 | pip-log.txt 209 | pip-delete-this-directory.txt 210 | 211 | # Unit test / coverage reports 212 | htmlcov/ 213 | .tox/ 214 | .nox/ 215 | .coverage 216 | .coverage.* 217 | nosetests.xml 218 | coverage.xml 219 | *.cover 220 | *.py,cover 221 | .hypothesis/ 222 | .pytest_cache/ 223 | cover/ 224 | 225 | # Translations 226 | *.mo 227 | *.pot 228 | 229 | # Django stuff: 230 | local_settings.py 231 | db.sqlite3 232 | db.sqlite3-journal 233 | 234 | # Flask stuff: 235 | instance/ 236 | .webassets-cache 237 | 238 | # Scrapy stuff: 239 | .scrapy 240 | 241 | # Sphinx documentation 242 | docs/_build/ 243 | 244 | # PyBuilder 245 | .pybuilder/ 246 | target/ 247 | 248 | # Jupyter Notebook 249 | .ipynb_checkpoints 250 | 251 | # IPython 252 | profile_default/ 253 | ipython_config.py 254 | 255 | # pyenv 256 | # For a library or package, you might want to ignore these files since the code is 257 | # intended to run in multiple environments; otherwise, check them in: 258 | # .python-version 259 | 260 | # pipenv 261 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 262 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 263 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 264 | # install all needed dependencies. 265 | #Pipfile.lock 266 | 267 | # poetry 268 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 269 | # This is especially recommended for binary packages to ensure reproducibility, and is more 270 | # commonly ignored for libraries. 271 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 272 | #poetry.lock 273 | 274 | # pdm 275 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 276 | #pdm.lock 277 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 278 | # in version control. 279 | # https://pdm.fming.dev/#use-with-ide 280 | .pdm.toml 281 | 282 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 283 | __pypackages__/ 284 | 285 | # Celery stuff 286 | celerybeat-schedule 287 | celerybeat.pid 288 | 289 | # SageMath parsed files 290 | *.sage.py 291 | 292 | # Environments 293 | .venv 294 | env/ 295 | venv/ 296 | ENV/ 297 | env.bak/ 298 | venv.bak/ 299 | 300 | # Spyder project settings 301 | .spyderproject 302 | .spyproject 303 | 304 | # Rope project settings 305 | .ropeproject 306 | 307 | # mkdocs documentation 308 | /site 309 | 310 | # mypy 311 | .mypy_cache/ 312 | .dmypy.json 313 | dmypy.json 314 | 315 | # Pyre type checker 316 | .pyre/ 317 | 318 | # pytype static type analyzer 319 | .pytype/ 320 | 321 | # Cython debug symbols 322 | cython_debug/ 323 | 324 | # PyCharm 325 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 326 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 327 | # and can be added to the global gitignore or merged into this file. For a more nuclear 328 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 329 | #.idea/ 330 | 331 | ### Python Patch ### 332 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 333 | poetry.toml 334 | 335 | # ruff 336 | .ruff_cache/ 337 | 338 | # LSP config files 339 | pyrightconfig.json 340 | 341 | # End of https://www.toptal.com/developers/gitignore/api/python,node,osx 342 | notes.txt 343 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | test: 2 | docker compose run backend pytest 3 | 4 | load-fixtures: 5 | docker compose run backend python scripts/sample_data.py 6 | 7 | update-dependencies: 8 | docker compose run frontend npm install 9 | docker compose build # Needed to ensure pip-compile for next step 10 | docker compose run backend pip-compile requirements-dev.ini 11 | docker compose build 12 | 13 | run: 14 | docker compose up -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building a FARM Stack App 2 | 3 | This repository contains an example to-do app, 4 | built with the FARM stack, 5 | consisting of [FastAPI], [React], and [MongoDB]. 6 | 7 | Its purpose is to demonstrate the following: 8 | 9 | * An efficient way of developing FARM apps with [Docker Compose] 10 | * Best practice for structuring a MongoDB Data Access Layer 11 | * Some example code to query and update a straightforward MongoDB schema 12 | * Interaction between React and a FastAPI service 13 | * An efficient way to test a data access layer against a real database, with [PyTest] 14 | 15 | # How to Run it 16 | 17 | You first need to configure the environment, by creating a text file called `.env` in the project directory (that's the directory that contains `compose.yml`). It should contain a MongoDB connection string as the variable `MDB_URI`: 18 | 19 | ```text 20 | export MONGODB_URI='mongodb+srv://YOURUSERNAME:YOURPASSWORDHERE@sandbox.ABCDEF.mongodb.net/todo_list_app?retryWrites=true&w=majority&appName=farm_stack_webinar' 21 | ``` 22 | 23 | You'll need to set it to _your_ MongoDB connection string, though, not mine. 24 | 25 | ## If You Have Just Installed 26 | 27 | If you have the [Just] task runner installed, then you should be able to get up-and-running with: 28 | 29 | ```shell 30 | just dependencies 31 | just load-fixtures 32 | just run 33 | ``` 34 | 35 | ## Without Just 36 | 37 | If you have [Docker] installed already, you can change to the project directory in your favourite terminal, and run the following to install the Node dependencies: 38 | 39 | ```shell 40 | # Install all Node dependencies within the Docker environment: 41 | docker compose run frontend npm install 42 | # Install Python dependencies into container: 43 | docker compose build 44 | ``` 45 | 46 | **Optional:** If you'd like to start off with some dummy data (this is recommended), you can run `docker compose run backend python scripts/sample_data.py` before starting up the cluster. 47 | 48 | Once you've followed these steps, you can spin up the entire development environment with: 49 | 50 | ```shell 51 | # Start the development cluster: 52 | docker compose up 53 | ``` 54 | 55 | Now you can visit your site at: http://localhost:8000/ 56 | 57 | 58 | [FastAPI]: https://fastapi.tiangolo.com/ 59 | [React]: https://react.dev/ 60 | [MongoDB]: https://www.mongodb.com/ 61 | [Docker Compose]: https://docs.docker.com/compose/ 62 | [Just]: https://just.systems/man/en/ 63 | [PyTest]: https://docs.pytest.org/ -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Common 2 | .dockerignore 3 | CHANGELOG.md 4 | docker-compose.yml 5 | Dockerfile 6 | 7 | # git 8 | .git 9 | .gitattributes 10 | .gitignore 11 | 12 | # Secrets 13 | .env 14 | .envrc 15 | 16 | # Project files 17 | .pytest_cache 18 | Dockerfile.production 19 | dist 20 | venv -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | WORKDIR /app 4 | COPY requirements-dev.txt ./ 5 | 6 | RUN --mount=type=cache,target=/root/.cache \ 7 | python -m pip install --upgrade -r ./requirements-dev.txt 8 | 9 | COPY . . 10 | RUN --mount=type=cache,target=/root/.cache \ 11 | python -m pip install -e . 12 | 13 | EXPOSE 3001 14 | 15 | CMD [ "python", "-m", "todo" ] -------------------------------------------------------------------------------- /backend/Dockerfile.production: -------------------------------------------------------------------------------- 1 | FROM 722245653955.dkr.ecr.us-east-1.amazonaws.com/python/uv-build:latest as build 2 | 3 | # Prepare a virtual environment. 4 | # This is cached until the Python version changes above. 5 | RUN --mount=type=cache,target=/root/.cache \ 6 | uv venv $UV_PROJECT_ENVIRONMENT 7 | 8 | # Synchronize DEPENDENCIES without the application itself. 9 | # This layer is cached until uv.lock or pyproject.toml change. 10 | # Since there's no point in shipping lock files, we move them 11 | # into a directory that is NOT copied into the runtime image. 12 | COPY pyproject.toml /_lock/ 13 | COPY uv.lock /_lock/ 14 | RUN --mount=type=cache,target=/root/.cache < "Item": 44 | return Item( 45 | id=doc["id"], 46 | label=doc["label"], 47 | checked=doc["checked"], 48 | ) 49 | 50 | class ToDoList(BaseModel): 51 | id: str 52 | name: str 53 | items: list[Item] 54 | 55 | @staticmethod 56 | def from_doc(doc) -> "ToDoList": 57 | return ToDoList( 58 | id=str(doc["_id"]), 59 | name=doc["name"], 60 | items=[Item.from_doc(item) for item in doc["items"]], 61 | ) 62 | 63 | client = AsyncIOMotorClient(MONGODB_URI) 64 | collection = client.get_default_database().get_collection(COLLECTION_NAME) 65 | pprint( 66 | ToDoList.from_doc(doc) 67 | if (doc := await collection.find_one()) is not None 68 | else None 69 | ) 70 | 71 | 72 | async def main(): 73 | await test_motor() 74 | await test_beanie() 75 | 76 | 77 | if __name__ == "__main__": 78 | asyncio.run(main()) 79 | -------------------------------------------------------------------------------- /backend/scripts/sample_data.py: -------------------------------------------------------------------------------- 1 | from random import Random 2 | import os 3 | import sys 4 | from uuid import uuid4 5 | 6 | from pymongo import MongoClient 7 | 8 | COLLECTION_NAME = "todo_lists" 9 | 10 | shopping_list = [ 11 | "apples", 12 | "bananas", 13 | "bread", 14 | "milk", 15 | "eggs", 16 | "butter", 17 | "chicken", 18 | "rice", 19 | "pasta", 20 | "tomatoes", 21 | "cheese", 22 | "yogurt", 23 | "spinach", 24 | "potatoes", 25 | "carrots", 26 | "onions", 27 | "garlic", 28 | "cereal", 29 | "orange juice", 30 | "coffee", 31 | ] 32 | 33 | cocktail_ingredients = [ 34 | "vodka", 35 | "rum", 36 | "gin", 37 | "triple sec", 38 | "lime juice", 39 | "simple syrup", 40 | "bitters", 41 | ] 42 | 43 | office_todo_list = [ 44 | "reply to emails", 45 | "prepare presentation", 46 | "attend team meeting", 47 | "review project report", 48 | "schedule client call", 49 | ] 50 | 51 | fastapi_react_tasks = [ 52 | "set up FastAPI server", 53 | "create API endpoints", 54 | "configure database connection", 55 | "build React components", 56 | "set up React routing", 57 | "connect React app to API", 58 | "deploy app to production", 59 | ] 60 | 61 | lists = { 62 | "Shopping": shopping_list, 63 | "Work To-Do List": office_todo_list, 64 | "Friday Cocktails": cocktail_ingredients, 65 | "This Demo": fastapi_react_tasks, 66 | } 67 | 68 | 69 | def main(argv=sys.argv[1:]): 70 | r = Random(42) 71 | 72 | client = MongoClient(os.environ["MONGODB_URI"]) 73 | db = client.get_default_database() 74 | todo_lists = db.get_collection(COLLECTION_NAME) 75 | todo_lists.drop() 76 | 77 | todo_lists.create_index({"name": 1}) 78 | 79 | for title, items in lists.items(): 80 | doc = { 81 | "name": title, 82 | "items": [ 83 | {"id": uuid4().hex, "label": item, "checked": bool(r.getrandbits(1))} 84 | for item in items 85 | ], 86 | } 87 | todo_lists.insert_one(doc) 88 | 89 | 90 | if __name__ == "__main__": 91 | main() 92 | -------------------------------------------------------------------------------- /backend/src/todo/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /backend/src/todo/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import uvicorn 4 | 5 | DEBUG = os.environ.get("DEBUG", "").strip().lower() in {"1", "true", "on", "yes"} 6 | 7 | 8 | def main(argv=sys.argv[1:]): 9 | print(f"Running with DEBUG={DEBUG}") 10 | try: 11 | uvicorn.run("todo.server:app", host="0.0.0.0", port=3001, reload=DEBUG) 12 | except KeyboardInterrupt: 13 | pass 14 | 15 | 16 | if __name__ == "__main__": 17 | main() 18 | -------------------------------------------------------------------------------- /backend/src/todo/dal_beanie.py: -------------------------------------------------------------------------------- 1 | from bson import ObjectId 2 | from motor.motor_asyncio import AsyncIOMotorDatabase 3 | from pymongo import ReturnDocument 4 | from beanie import Document, init_beanie 5 | 6 | from pydantic import BaseModel 7 | 8 | from uuid import uuid4 9 | 10 | 11 | class ListSummary(Document): 12 | class Settings: 13 | name = "todo_lists" 14 | 15 | name: str 16 | item_count: int 17 | 18 | 19 | class ToDoListItem(BaseModel): 20 | id: str # This is a generated UUID, not a MongoDB ObjectID 21 | label: str 22 | checked: bool 23 | 24 | @staticmethod 25 | def from_doc(item) -> "ToDoListItem": 26 | return ToDoListItem( 27 | id=item["id"], 28 | label=item["label"], 29 | checked=item["checked"], 30 | ) 31 | 32 | 33 | class ToDoList(Document): 34 | class Settings: 35 | name = "todo_lists" 36 | 37 | name: str 38 | items: list[ToDoListItem] 39 | 40 | 41 | async def get_instance(database: AsyncIOMotorDatabase) -> "ToDoDALBeanie": 42 | return await ToDoDALBeanie(database) 43 | 44 | 45 | class ToDoDALBeanie: 46 | def __init__(self, database: AsyncIOMotorDatabase): 47 | self._database = database 48 | 49 | def __await__(self): 50 | return self.create().__await__() 51 | 52 | async def create(self): 53 | print("initializing") 54 | await init_beanie( 55 | database=self._database, document_models=[ListSummary, ToDoList] 56 | ) 57 | return self 58 | 59 | async def list_todo_lists(self, session=None): 60 | async for item in ListSummary.aggregate( 61 | [ 62 | { 63 | "$project": { 64 | "name": 1, 65 | "item_count": {"$size": "$items"}, 66 | } 67 | }, 68 | { 69 | "$sort": {"name": 1}, 70 | }, 71 | ], 72 | projection_model=ListSummary, 73 | session=session, 74 | ): 75 | yield item 76 | 77 | async def create_todo_list(self, name: str, session=None) -> ObjectId: 78 | new_list = ToDoList(name=name, items=[]) 79 | await new_list.save( 80 | session=session, 81 | ) 82 | return new_list.id 83 | 84 | async def get_todo_list(self, id: str | ObjectId, session=None) -> ToDoList: 85 | return await ToDoList.get( 86 | ObjectId(id), 87 | session=session, 88 | ) 89 | 90 | async def delete_todo_list(self, id: str | ObjectId, session=None) -> bool: 91 | todo = await ToDoList.get(ObjectId(id), session=session) 92 | response = await todo.delete(session=session) 93 | return response.deleted_count == 1 94 | 95 | async def create_item( 96 | self, 97 | id: str | ObjectId, 98 | label: str, 99 | session=None, 100 | ) -> ToDoList | None: 101 | todo = await ToDoList.get(ObjectId(id), session=session) 102 | await todo.update( 103 | { 104 | "$push": { 105 | "items": { 106 | "id": uuid4().hex, 107 | "label": label, 108 | "checked": False, 109 | } 110 | } 111 | }, 112 | session=session, 113 | ) 114 | return todo 115 | 116 | async def set_checked_state( 117 | self, 118 | doc_id: str | ObjectId, 119 | item_id: str, 120 | checked_state: bool, 121 | session=None, 122 | ) -> ToDoList | None: 123 | doc = await ToDoList.get_motor_collection().find_one_and_update( 124 | {"_id": ObjectId(doc_id), "items.id": item_id}, 125 | {"$set": {"items.$.checked": checked_state}}, 126 | session=session, 127 | return_document=ReturnDocument.AFTER, 128 | ) 129 | if doc: 130 | return ToDoList.model_validate(doc) 131 | 132 | async def delete_item( 133 | self, 134 | doc_id: str | ObjectId, 135 | item_id: str, 136 | session=None, 137 | ) -> ToDoList | None: 138 | result = await ToDoList.get_motor_collection().find_one_and_update( 139 | {"_id": ObjectId(doc_id)}, 140 | {"$pull": {"items": {"id": item_id}}}, 141 | session=session, 142 | return_document=ReturnDocument.AFTER, 143 | ) 144 | if result: 145 | return ToDoList.model_validate(result) 146 | -------------------------------------------------------------------------------- /backend/src/todo/dal_motor.py: -------------------------------------------------------------------------------- 1 | from bson import ObjectId 2 | from motor.motor_asyncio import AsyncIOMotorDatabase 3 | from pymongo import ReturnDocument 4 | 5 | from pydantic import BaseModel, Field 6 | 7 | from uuid import uuid4 8 | 9 | 10 | class ListSummary(BaseModel): 11 | id: str = Field(alias="_id") 12 | name: str 13 | item_count: int 14 | 15 | @staticmethod 16 | def from_doc(doc) -> "ListSummary": 17 | return ListSummary( 18 | _id=str(doc["_id"]), 19 | name=doc["name"], 20 | item_count=doc["item_count"], 21 | ) 22 | 23 | 24 | class ToDoListItem(BaseModel): 25 | id: str 26 | label: str 27 | checked: bool 28 | 29 | @staticmethod 30 | def from_doc(item) -> "ToDoListItem": 31 | return ToDoListItem( 32 | id=item["id"], 33 | label=item["label"], 34 | checked=item["checked"], 35 | ) 36 | 37 | 38 | class ToDoList(BaseModel): 39 | id: str = Field(alias="_id") 40 | name: str 41 | items: list[ToDoListItem] 42 | 43 | @staticmethod 44 | def from_doc(doc) -> "ToDoList": 45 | return ToDoList( 46 | _id=str(doc["_id"]), 47 | name=doc["name"], 48 | items=[ToDoListItem.from_doc(item) for item in doc["items"]], 49 | ) 50 | 51 | 52 | async def get_instance(database: AsyncIOMotorDatabase): 53 | return ToDoDALMotor(database) 54 | 55 | 56 | class ToDoDALMotor: 57 | def __init__(self, database: AsyncIOMotorDatabase): 58 | self._todo_collection = database.get_collection("todo_lists") 59 | 60 | async def list_todo_lists(self, session=None): 61 | async for doc in self._todo_collection.find( 62 | {}, 63 | projection={ 64 | "name": 1, 65 | "item_count": {"$size": "$items"}, 66 | }, 67 | sort={"name": 1}, 68 | session=session, 69 | ): 70 | yield ListSummary.from_doc(doc) 71 | 72 | async def create_todo_list(self, name: str, session=None) -> str: 73 | response = await self._todo_collection.insert_one( 74 | {"name": name, "items": []}, 75 | session=session, 76 | ) 77 | return str(response.inserted_id) 78 | 79 | async def get_todo_list(self, id: str | ObjectId, session=None) -> ToDoList: 80 | doc = await self._todo_collection.find_one( 81 | {"_id": ObjectId(id)}, 82 | session=session, 83 | ) 84 | return ToDoList.from_doc(doc) 85 | 86 | async def delete_todo_list(self, id: str | ObjectId, session=None) -> bool: 87 | response = await self._todo_collection.delete_one( 88 | {"_id": ObjectId(id)}, 89 | session=session, 90 | ) 91 | return response.deleted_count == 1 92 | 93 | async def create_item( 94 | self, 95 | id: str | ObjectId, 96 | label: str, 97 | session=None, 98 | ) -> ToDoList | None: 99 | result = await self._todo_collection.find_one_and_update( 100 | {"_id": ObjectId(id)}, 101 | { 102 | "$push": { 103 | "items": { 104 | "id": uuid4().hex, 105 | "label": label, 106 | "checked": False, 107 | } 108 | } 109 | }, 110 | session=session, 111 | return_document=ReturnDocument.AFTER, 112 | ) 113 | if result: 114 | return ToDoList.from_doc(result) 115 | 116 | async def set_checked_state( 117 | self, 118 | doc_id: str | ObjectId, 119 | item_id: str, 120 | checked_state: bool, 121 | session=None, 122 | ) -> ToDoList | None: 123 | 124 | result = await self._todo_collection.find_one_and_update( 125 | {"_id": ObjectId(doc_id), "items.id": item_id}, 126 | {"$set": {"items.$.checked": checked_state}}, 127 | session=session, 128 | return_document=ReturnDocument.AFTER, 129 | ) 130 | if result: 131 | return ToDoList.from_doc(result) 132 | 133 | async def delete_item( 134 | self, 135 | doc_id: str | ObjectId, 136 | item_id: str, 137 | session=None, 138 | ) -> ToDoList | None: 139 | result = await self._todo_collection.find_one_and_update( 140 | {"_id": ObjectId(doc_id)}, 141 | {"$pull": {"items": {"id": item_id}}}, 142 | session=session, 143 | return_document=ReturnDocument.AFTER, 144 | ) 145 | if result: 146 | return ToDoList.from_doc(result) 147 | -------------------------------------------------------------------------------- /backend/src/todo/server.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | import os 3 | 4 | from fastapi import FastAPI, status 5 | from motor.motor_asyncio import AsyncIOMotorClient 6 | from pydantic import BaseModel 7 | 8 | from .dal_beanie import get_instance, ListSummary, ToDoList 9 | 10 | ## Uncomment this line to use motor directly, instead of via Beanie ODM: 11 | # from .dal_motor import get_instance, ListSummary, ToDoList 12 | 13 | DEBUG = os.environ.get("DEBUG", "").strip().lower() in {"1", "true", "on", "yes"} 14 | MONGODB_URI = os.environ["MONGODB_URI"] 15 | 16 | 17 | @asynccontextmanager 18 | async def lifespan(app: FastAPI): 19 | # Startup: 20 | client = AsyncIOMotorClient(MONGODB_URI) 21 | database = client.get_default_database() 22 | 23 | # Ensure the database is available: 24 | pong = await database.command("ping") 25 | if int(pong["ok"]) != 1: 26 | raise Exception("Cluster connection is not okay!") 27 | 28 | app.todo_dal = await get_instance(database) 29 | 30 | # Yield back to FastAPI Application: 31 | yield 32 | 33 | # Shutdown: 34 | client.close() 35 | 36 | 37 | app = FastAPI(lifespan=lifespan, debug=DEBUG) 38 | 39 | 40 | @app.get("/api/lists") 41 | async def get_all_lists() -> list[ListSummary]: 42 | return [i async for i in app.todo_dal.list_todo_lists()] 43 | 44 | 45 | class NewList(BaseModel): 46 | name: str 47 | 48 | 49 | class NewListResponse(BaseModel): 50 | id: str 51 | name: str 52 | 53 | 54 | @app.post("/api/lists", status_code=status.HTTP_201_CREATED) 55 | async def create_todo_list(new_list: NewList) -> NewListResponse: 56 | return NewListResponse( 57 | id=str(await app.todo_dal.create_todo_list(new_list.name)), 58 | name=new_list.name, 59 | ) 60 | 61 | 62 | @app.get("/api/lists/{list_id}") 63 | async def get_list(list_id: str) -> ToDoList: 64 | """Get a single to-do list""" 65 | return await app.todo_dal.get_todo_list(list_id) 66 | 67 | 68 | @app.delete("/api/lists/{list_id}") 69 | async def delete_list(list_id: str) -> bool: 70 | return await app.todo_dal.delete_todo_list(list_id) 71 | 72 | 73 | class NewItem(BaseModel): 74 | label: str 75 | 76 | 77 | class NewItemResponse(BaseModel): 78 | id: str 79 | label: str 80 | 81 | 82 | @app.post( 83 | "/api/lists/{list_id}/items/", 84 | status_code=status.HTTP_201_CREATED, 85 | ) 86 | async def create_item(list_id: str, new_item: NewItem) -> ToDoList: 87 | return await app.todo_dal.create_item(list_id, new_item.label) 88 | 89 | 90 | @app.delete("/api/lists/{list_id}/items/{item_id}") 91 | async def delete_item(list_id: str, item_id: str) -> ToDoList: 92 | return await app.todo_dal.delete_item(list_id, item_id) 93 | 94 | 95 | class ToDoItemUpdate(BaseModel): 96 | item_id: str 97 | checked_state: bool 98 | 99 | 100 | @app.patch("/api/lists/{list_id}/checked_state") 101 | async def set_checked_state(list_id: str, update: ToDoItemUpdate) -> ToDoList: 102 | return await app.todo_dal.set_checked_state( 103 | list_id, update.item_id, update.checked_state 104 | ) 105 | -------------------------------------------------------------------------------- /backend/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | import pytest_asyncio 5 | from motor.motor_asyncio import AsyncIOMotorClient 6 | 7 | 8 | @pytest_asyncio.fixture(scope="session") 9 | async def motor_client(): 10 | client = AsyncIOMotorClient(os.environ["MONGODB_URI"]) 11 | pong = await client.local.command("ping") 12 | assert int(pong["ok"]) == 1 13 | yield client 14 | client.close() 15 | 16 | 17 | @pytest_asyncio.fixture(scope="session") 18 | def app_db(motor_client): 19 | return motor_client.get_default_database() 20 | 21 | 22 | # @pytest_asyncio.fixture(scope="session") 23 | # def todo_collection(app_db): 24 | # return app_db.get_collection(COLLECTION_NAME) 25 | 26 | 27 | @pytest_asyncio.fixture(scope="session") 28 | async def rollback_session(motor_client: AsyncIOMotorClient): 29 | """ 30 | This fixture provides a session that will be aborted at the end of the test, to clean up any written data. 31 | """ 32 | session = await motor_client.start_session() 33 | session.start_transaction() 34 | try: 35 | yield session 36 | finally: 37 | await session.abort_transaction() 38 | -------------------------------------------------------------------------------- /backend/tests/test_dal_beanie.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import todo.dal_beanie as dal 4 | from todo.dal_beanie import ToDoDALBeanie 5 | 6 | 7 | async def get_friday_cocktails(todos_dal: ToDoDALBeanie): 8 | # Use list_todo_lists to obtain a valid list summary: 9 | return await anext(aiter(todos_dal.list_todo_lists())) 10 | 11 | 12 | @pytest.mark.asyncio(scope="session") 13 | async def test_list_todos(app_db): 14 | todos_dal = await dal.get_instance(app_db) 15 | cursor = todos_dal.list_todo_lists() 16 | summaries = [summary async for summary in cursor] 17 | 18 | assert len(summaries) == 4 19 | 20 | assert summaries[0].name == "Friday Cocktails" 21 | 22 | 23 | @pytest.mark.asyncio(scope="session") 24 | async def test_get_todo_list(app_db): 25 | todos_dal = await dal.get_instance(app_db) 26 | an_id = (await get_friday_cocktails(todos_dal)).id 27 | 28 | friday_cocktails = await todos_dal.get_todo_list(an_id) 29 | assert friday_cocktails.name == "Friday Cocktails" 30 | assert friday_cocktails.items[0].label == "vodka" 31 | 32 | 33 | @pytest.mark.asyncio(scope="session") 34 | async def test_create_item(app_db, rollback_session): 35 | todos_dal = await dal.get_instance(app_db) 36 | an_id = (await get_friday_cocktails(todos_dal)).id 37 | 38 | await todos_dal.create_item(an_id, "pytest dummy item", session=rollback_session) 39 | 40 | friday_cocktails = await todos_dal.get_todo_list(an_id, session=rollback_session) 41 | assert friday_cocktails.items[-1].label == "pytest dummy item" 42 | 43 | 44 | @pytest.mark.asyncio(scope="session") 45 | async def test_delete_item(app_db, rollback_session): 46 | todos_dal = await dal.get_instance(app_db) 47 | an_id = (await get_friday_cocktails(todos_dal)).id 48 | 49 | todo = await todos_dal.get_todo_list(an_id, session=rollback_session) 50 | todo_id = todo.id 51 | item_id = todo.items[3].id 52 | label = todo.items[3].label 53 | 54 | items = todo.items[1:4] 55 | 56 | await todos_dal.delete_item(todo_id, item_id, session=rollback_session) 57 | 58 | friday_cocktails = await todos_dal.get_todo_list(todo_id, session=rollback_session) 59 | assert friday_cocktails.items[2].label != items[0].label 60 | assert friday_cocktails.items[2].id != items[0].id 61 | assert friday_cocktails.items[3].label != label 62 | assert friday_cocktails.items[4].label != items[2].label 63 | assert friday_cocktails.items[4].id != items[2].id 64 | 65 | 66 | @pytest.mark.asyncio(scope="session") 67 | async def test_set_checked_state(app_db, rollback_session): 68 | todos_dal = await dal.get_instance(app_db) 69 | doc_id = (await get_friday_cocktails(todos_dal)).id 70 | 71 | # First get the existing doc, so we can get an existing item state: 72 | todo = await todos_dal.get_todo_list(doc_id, session=rollback_session) 73 | 74 | item_index = 2 # A randomly chosen item index 75 | item = todo.items[item_index] 76 | item_state = item.checked 77 | 78 | # Set the state to *the same state* (no change): 79 | new_state = item_state 80 | new_todo = await todos_dal.set_checked_state( 81 | doc_id, item.id, new_state, session=rollback_session 82 | ) 83 | # Check the returned document: 84 | assert new_todo.items[item_index].checked == new_state 85 | # Fetch it from the database, check that it's the same: 86 | friday_cocktails = await todos_dal.get_todo_list(doc_id, session=rollback_session) 87 | assert friday_cocktails.items[item_index].checked == new_state 88 | 89 | # Set the state to the opposite state: 90 | new_state = not item_state 91 | new_todo = await todos_dal.set_checked_state( 92 | doc_id, item.id, new_state, session=rollback_session 93 | ) 94 | # Check the returned document: 95 | assert new_todo.items[item_index].checked == new_state 96 | # Fetch it from the database, check that it's the same: 97 | friday_cocktails = await todos_dal.get_todo_list(doc_id, session=rollback_session) 98 | assert friday_cocktails.items[item_index].checked == new_state 99 | 100 | 101 | @pytest.mark.asyncio(scope="session") 102 | async def test_create_todo_list(app_db, rollback_session): 103 | todos_dal = await dal.get_instance(app_db) 104 | 105 | new_list_id = await todos_dal.create_todo_list( 106 | "pytest test list should be removed", 107 | session=rollback_session, 108 | ) 109 | 110 | new_list = await todos_dal.get_todo_list(new_list_id, session=rollback_session) 111 | assert new_list.id == new_list_id 112 | assert new_list.name == "pytest test list should be removed" 113 | assert new_list.items == [] 114 | -------------------------------------------------------------------------------- /backend/tests/test_dal_motor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from todo.dal_motor import ToDoDALMotor 4 | 5 | 6 | async def get_friday_cocktails(todos_dal: ToDoDALMotor): 7 | # Use list_todo_lists to obtain a valid list summary: 8 | return await anext(aiter(todos_dal.list_todo_lists())) 9 | 10 | 11 | @pytest.mark.asyncio(scope="session") 12 | async def test_list_todos(app_db): 13 | todos_dal = ToDoDALMotor(app_db) 14 | cursor = todos_dal.list_todo_lists() 15 | summaries = [summary async for summary in cursor] 16 | 17 | assert len(summaries) == 4 18 | 19 | assert summaries[0].name == "Friday Cocktails" 20 | 21 | 22 | @pytest.mark.asyncio(scope="session") 23 | async def test_get_todo_list(app_db): 24 | todos_dal = ToDoDALMotor(app_db) 25 | an_id = (await get_friday_cocktails(todos_dal)).id 26 | 27 | friday_cocktails = await todos_dal.get_todo_list(an_id) 28 | assert friday_cocktails.name == "Friday Cocktails" 29 | assert friday_cocktails.items[0].label == "vodka" 30 | 31 | 32 | @pytest.mark.asyncio(scope="session") 33 | async def test_create_item(app_db, rollback_session): 34 | todos_dal = ToDoDALMotor(app_db) 35 | an_id = (await get_friday_cocktails(todos_dal)).id 36 | 37 | await todos_dal.create_item(an_id, "pytest dummy item", session=rollback_session) 38 | 39 | friday_cocktails = await todos_dal.get_todo_list(an_id, session=rollback_session) 40 | assert friday_cocktails.items[-1].label == "pytest dummy item" 41 | 42 | 43 | @pytest.mark.asyncio(scope="session") 44 | async def test_delete_item(app_db, rollback_session): 45 | todos_dal = ToDoDALMotor(app_db) 46 | an_id = (await get_friday_cocktails(todos_dal)).id 47 | 48 | todo = await todos_dal.get_todo_list(an_id, session=rollback_session) 49 | todo_id = todo.id 50 | item_id = todo.items[3].id 51 | label = todo.items[3].label 52 | 53 | items = todo.items[1:4] 54 | 55 | await todos_dal.delete_item(todo_id, item_id, session=rollback_session) 56 | 57 | friday_cocktails = await todos_dal.get_todo_list(todo_id, session=rollback_session) 58 | assert friday_cocktails.items[2].label != items[0].label 59 | assert friday_cocktails.items[2].id != items[0].id 60 | assert friday_cocktails.items[3].label != label 61 | assert friday_cocktails.items[4].label != items[2].label 62 | assert friday_cocktails.items[4].id != items[2].id 63 | 64 | 65 | @pytest.mark.asyncio(scope="session") 66 | async def test_set_checked_state(app_db, rollback_session): 67 | todos_dal = ToDoDALMotor(app_db) 68 | doc_id = (await get_friday_cocktails(todos_dal)).id 69 | 70 | # First get the existing doc, so we can get an existing item state: 71 | todo = await todos_dal.get_todo_list(doc_id, session=rollback_session) 72 | 73 | item_index = 2 # A randomly chosen item index 74 | item = todo.items[item_index] 75 | item_state = item.checked 76 | 77 | # Set the state to *the same state* (no change): 78 | new_state = item_state 79 | new_todo = await todos_dal.set_checked_state( 80 | doc_id, item.id, new_state, session=rollback_session 81 | ) 82 | # Check the returned document: 83 | assert new_todo.items[item_index].checked == new_state 84 | # Fetch it from the database, check that it's the same: 85 | friday_cocktails = await todos_dal.get_todo_list(doc_id, session=rollback_session) 86 | assert friday_cocktails.items[item_index].checked == new_state 87 | 88 | # Set the state to the opposite state: 89 | new_state = not item_state 90 | new_todo = await todos_dal.set_checked_state( 91 | doc_id, item.id, new_state, session=rollback_session 92 | ) 93 | # Check the returned document: 94 | assert new_todo.items[item_index].checked == new_state 95 | # Fetch it from the database, check that it's the same: 96 | friday_cocktails = await todos_dal.get_todo_list(doc_id, session=rollback_session) 97 | assert friday_cocktails.items[item_index].checked == new_state 98 | 99 | 100 | @pytest.mark.asyncio(scope="session") 101 | async def test_create_todo_list(app_db, rollback_session): 102 | todos_dal = ToDoDALMotor(app_db) 103 | 104 | new_list_id = await todos_dal.create_todo_list( 105 | "pytest test list should be removed", 106 | session=rollback_session, 107 | ) 108 | 109 | new_list = await todos_dal.get_todo_list(new_list_id, session=rollback_session) 110 | assert new_list.id == new_list_id 111 | assert new_list.name == "pytest test list should be removed" 112 | assert new_list.items == [] 113 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | name: todo-app 2 | services: 3 | nginx: 4 | image: nginx:1.17 5 | volumes: 6 | - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf 7 | ports: 8 | - 8000:80 9 | depends_on: 10 | - backend 11 | - frontend 12 | frontend: 13 | image: "node:22" 14 | user: "node" 15 | working_dir: /home/node/app 16 | environment: 17 | - NODE_ENV=development 18 | - WDS_SOCKET_PORT=0 19 | volumes: 20 | - ./frontend/:/home/node/app 21 | expose: 22 | - "3000" 23 | command: "npm start" 24 | backend: 25 | image: todo-app/backend 26 | build: ./backend 27 | volumes: 28 | - ./backend/:/app 29 | expose: 30 | - "3001" 31 | ports: 32 | - "8001:3001" 33 | environment: 34 | - DEBUG=true 35 | - WATCHFILES_FORCE_POLLING=true # Force FastAPI to poll filesystem. 36 | env_file: 37 | - path: ./.env 38 | required: true -------------------------------------------------------------------------------- /farm-stack.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "backend" 5 | }, 6 | { 7 | "path": "frontend" 8 | } 9 | ], 10 | "settings": {} 11 | } 12 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 13 | 14 | The page will reload when you make changes.\ 15 | You may also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!** 35 | 36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. 39 | 40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.17.0", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "axios": "^1.7.2", 10 | "react": "^18.3.1", 11 | "react-dom": "^18.3.1", 12 | "react-icons": "^5.2.1", 13 | "react-scripts": "5.0.1", 14 | "web-vitals": "^2.1.4" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": [ 24 | "react-app", 25 | "react-app/jest" 26 | ] 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-developer/farm-stack-to-do-app/31ca8a267d74e7bfc4e4850278c97a7b4912b8d7/frontend/public/favicon.png -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | To-Do List 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "To-Do List", 3 | "name": "A Sample FARM Stack application, by MongoDB", 4 | "icons": [ 5 | { 6 | "src": "favicon.png", 7 | "sizes": "512x512", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "lightgray", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | } 3 | 4 | h1 { 5 | text-align: center; 6 | } 7 | 8 | .loading { 9 | text-align: center; 10 | } 11 | 12 | .App-logo { 13 | height: 40vmin; 14 | pointer-events: none; 15 | } 16 | 17 | @media (prefers-reduced-motion: no-preference) { 18 | .App-logo { 19 | animation: App-logo-spin infinite 20s linear; 20 | } 21 | } 22 | 23 | .App-header { 24 | background-color: #282c34; 25 | min-height: 100vh; 26 | display: flex; 27 | flex-direction: column; 28 | align-items: center; 29 | justify-content: center; 30 | font-size: calc(10px + 2vmin); 31 | color: white; 32 | } 33 | 34 | .App-link { 35 | color: #61dafb; 36 | } 37 | 38 | @keyframes App-logo-spin { 39 | from { 40 | transform: rotate(0deg); 41 | } 42 | to { 43 | transform: rotate(360deg); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import axios from "axios"; 3 | import "./App.css"; 4 | import "./ListTodoLists"; 5 | import ListToDoLists from "./ListTodoLists"; 6 | import ToDoList from "./ToDoList"; 7 | 8 | function App() { 9 | const [listSummaries, setListSummaries] = useState(null); 10 | const [selectedItem, setSelectedItem] = useState(null); 11 | 12 | useEffect(() => { 13 | reloadData().catch(console.error); 14 | }, []); 15 | 16 | async function reloadData() { 17 | const response = await axios.get("/api/lists"); 18 | const data = await response.data; 19 | setListSummaries(data); 20 | } 21 | 22 | function handleNewToDoList(newName) { 23 | const updateData = async () => { 24 | const newListData = { 25 | name: newName, 26 | }; 27 | 28 | await axios.post(`/api/lists`, newListData); 29 | reloadData().catch(console.error); 30 | }; 31 | updateData(); 32 | } 33 | 34 | function handleDeleteToDoList(id) { 35 | const updateData = async () => { 36 | await axios.delete(`/api/lists/${id}`); 37 | reloadData().catch(console.error); 38 | }; 39 | updateData(); 40 | } 41 | 42 | function handleSelectList(id) { 43 | console.log("Selecting item", id); 44 | setSelectedItem(id); 45 | } 46 | 47 | function backToList() { 48 | setSelectedItem(null); 49 | reloadData().catch(console.error); 50 | } 51 | 52 | if (selectedItem === null) { 53 | return ( 54 |
55 | 61 |
62 | ); 63 | } else { 64 | return ( 65 |
66 | 67 |
68 | ); 69 | } 70 | } 71 | 72 | export default App; 73 | -------------------------------------------------------------------------------- /frontend/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/src/ListTodoLists.css: -------------------------------------------------------------------------------- 1 | .ListToDoLists .summary { 2 | border: 1px solid lightgray; 3 | padding: 1em; 4 | margin: 1em; 5 | cursor: pointer; 6 | display: flex; 7 | } 8 | 9 | .ListToDoLists .count { 10 | padding-left: 1ex; 11 | color: blueviolet; 12 | font-size: 92%; 13 | 14 | } -------------------------------------------------------------------------------- /frontend/src/ListTodoLists.js: -------------------------------------------------------------------------------- 1 | import "./ListTodoLists.css"; 2 | import { useRef } from "react"; 3 | import { BiSolidTrash } from "react-icons/bi"; 4 | 5 | /** 6 | * 7 | * @param {Object} state - The state to be displayed 8 | * @param {null|array} state.listSummaries - null, if the data is still being loaded, or an array of list summary objects. 9 | */ 10 | function ListToDoLists({ 11 | listSummaries, 12 | handleSelectList, 13 | handleNewToDoList, 14 | handleDeleteToDoList, 15 | }) { 16 | const labelRef = useRef(); 17 | 18 | if (listSummaries === null) { 19 | return
Loading to-do lists ...
; 20 | } else if (listSummaries.length === 0) { 21 | return
There are no to-do lists!
; 22 | } 23 | return ( 24 |
25 |

All To-Do Lists

26 |
27 | 31 | 38 |
39 | {listSummaries.map((summary) => { 40 | return ( 41 |
handleSelectList(summary._id)} 45 | > 46 | {summary.name} 47 | ({summary.item_count} items) 48 | 49 | { 52 | evt.stopPropagation(); 53 | handleDeleteToDoList(summary._id); 54 | }} 55 | > 56 | 57 | 58 |
59 | ); 60 | })} 61 |
62 | ); 63 | } 64 | 65 | export default ListToDoLists; 66 | -------------------------------------------------------------------------------- /frontend/src/ToDoList.css: -------------------------------------------------------------------------------- 1 | .ToDoList .back { 2 | margin: 0 1em; 3 | padding: 1em; 4 | float: left; 5 | 6 | } 7 | 8 | .ToDoList .item { 9 | border: 1px solid lightgray; 10 | padding: 1em; 11 | margin: 1em; 12 | cursor: pointer; 13 | display: flex; 14 | } 15 | 16 | .ToDoList .label { 17 | margin-left: 1ex; 18 | } 19 | 20 | .ToDoList .checked .label { 21 | text-decoration: line-through; 22 | color: lightgray; 23 | } -------------------------------------------------------------------------------- /frontend/src/ToDoList.js: -------------------------------------------------------------------------------- 1 | import "./ToDoList.css"; 2 | 3 | import { useEffect, useState, useRef } from "react"; 4 | 5 | import axios from "axios"; 6 | import { BiSolidTrash } from "react-icons/bi"; 7 | 8 | function ToDoList({ listId, handleBackButton }) { 9 | let labelRef = useRef(); 10 | const [listData, setListData] = useState(null); 11 | 12 | useEffect(() => { 13 | const fetchData = async () => { 14 | const response = await axios.get(`/api/lists/${listId}`); 15 | const newData = await response.data; 16 | setListData(newData); 17 | }; 18 | fetchData(); 19 | }, [listId]); 20 | 21 | function handleCreateItem(label) { 22 | const updateData = async () => { 23 | const response = await axios.post(`/api/lists/${listData._id}/items/`, { 24 | label: label, 25 | }); 26 | setListData(await response.data); 27 | }; 28 | updateData(); 29 | } 30 | 31 | function handleDeleteItem(id) { 32 | const updateData = async () => { 33 | const response = await axios.delete( 34 | `/api/lists/${listData._id}/items/${id}` 35 | ); 36 | setListData(await response.data); 37 | }; 38 | updateData(); 39 | } 40 | 41 | function handleCheckToggle(itemId, newState) { 42 | const updateData = async () => { 43 | const response = await axios.patch( 44 | `/api/lists/${listData._id}/checked_state`, 45 | { 46 | item_id: itemId, 47 | checked_state: newState, 48 | } 49 | ); 50 | setListData(await response.data); 51 | }; 52 | updateData(); 53 | } 54 | 55 | if (listData === null) { 56 | ; 59 | return
Loading to-do list ...
; 60 | } 61 | return ( 62 |
63 | 66 |

List: {listData.name}

67 |
68 | 72 | 79 |
80 | {listData.items.length > 0 ? ( 81 | listData.items.map((item) => { 82 | return ( 83 |
handleCheckToggle(item.id, !item.checked)} 87 | > 88 | {item.checked ? "✅" : "⬜️"} 89 | {item.label} 90 | 91 | { 94 | evt.stopPropagation(); 95 | handleDeleteItem(item.id); 96 | }} 97 | > 98 | 99 | 100 |
101 | ); 102 | }) 103 | ) : ( 104 |
There are currently no items.
105 | )} 106 |
107 | ); 108 | } 109 | 110 | export default ToDoList; 111 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | font-size: 12pt; 9 | } 10 | 11 | input, button { 12 | font-size: 1em; 13 | } 14 | 15 | code { 16 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 17 | monospace; 18 | } 19 | 20 | .box { 21 | border: 1px solid lightgray; 22 | padding: 1em; 23 | margin: 1em; 24 | } 25 | 26 | .flex { 27 | flex: 1; 28 | } -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name farm_intro; 4 | 5 | location / { 6 | proxy_pass http://frontend:3000; 7 | 8 | # proxy_http_version 1.1; 9 | proxy_set_header Upgrade $http_upgrade; 10 | proxy_set_header Connection "upgrade"; 11 | } 12 | 13 | location /api { 14 | proxy_pass http://backend:3001/api; 15 | } 16 | } --------------------------------------------------------------------------------