diff --git a/README.md b/README.md index 60cac25..bddffcb 100644 --- a/README.md +++ b/README.md @@ -1 +1,126 @@ -# Project: Shared shopping list +# Shared shopping list + +## Overview + +This web application allows users to create and manage shared shopping lists. +Users can create new shopping lists, add items to them, and mark items as +collected. The application displays a list of all active shopping lists, as well +as a page for each individual shopping list showing all its items. + +The application is deployed at https://shopper.fly.dev/ using +[Fly.io](https://fly.io/). Feel free to try it out! + +The web application is built using Deno, a JavaScript/TypeScript runtime with +built-in support for modern web development. The user interface is rendered +using the Eta template engine, which allows for embedding JavaScript code in +HTML templates. The application uses a Postgres database to store data about +shopping lists and items. + +## Usage + +To create a new shopping list, navigate to the "Shopping lists" page and enter a +name for the list in the "Name" field. Then, click the "Create!" button. + +To add an item to a shopping list, navigate to the page for the list and enter +the item's name in the "Name" field. Then, click the "Add!" button. + +To mark an item as collected, navigate to the page for the shopping list +containing the item and click the "Mark collected!" button next to the item. + +To deactivate a shopping list, navigate to the page for the list and click the +"Deactivate list!" button. Deactivated lists will no longer appear on the +"Shopping lists" page. + +## Additional functionality + +The application also displays the total number of active shopping lists and +items on the main page. + +The items in each shopping list are sorted by their collected status, with +collected items appearing at the bottom of the list. + +The application is designed to be scalable and can handle multiple concurrent +connections. It uses a connection pool to manage connections to the database. + +## File structure + +- deps.js: file containing functions for starting the server and rendering + templates +- controllers: directory containing files for handling different routes and + their corresponding logic + - mainController.js: file containing functions for handling the main page + routes + - listController.js: file containing functions for handling the shopping list + routes + - itemController.js: file containing functions for handling the shopping list + item routes +- services: directory containing files for interacting with the database + - mainService.js: file containing functions for interacting with the main page + data + - listService.js: file containing functions for interacting with the shopping + list data + - itemService.js: file containing functions for interacting with the shopping + list item data +- database: directory containing the file for connecting to the database + - database.js: file containing functions for executing queries on the database +- views: directory containing the templates for the different pages + - layouts: directory containing the layout template + - layout.eta: layout template for all pages + - main.eta: template for the main page + - lists.eta: template for the shopping lists page + - list.eta: template for an individual shopping list page +- utils: directory containing utility functions + - requestUtils.js: file containing functions for handling HTTP requests + +## Testing + +End-to-end tests are done using Playwright, which is located in the +`e2e-playwright` directory. + +The tests are designed to ensure that the application behaves as expected when +creating, viewing, updating, and deleting a shopping list. + +The first test, "Can create a new shopping list," verifies that a new shopping +list can be created by visiting the "/lists" page, filling out a form with the +name of the list, and submitting the form. The test then verifies that the list +appears on the page by checking for the presence of a link with the list's name. + +The second test, "Can open a shopping list page," verifies that a shopping list +page can be opened by clicking on a link with the list's name on the "/lists" +page. The test then verifies that the page has the correct title by checking for +the presence of an h1 element with the list's name. + +The third test, "Can add an item to a shopping list," verifies that an item can +be added to a shopping list by visiting the list page, filling out a form with +the item's name, and submitting the form. The test then verifies that the item +appears in the list by checking for the presence of a table cell with the item's +name. + +The fourth test, "Can mark an item as collected," verifies that an item in a +shopping list can be marked as collected by clicking on a "Mark collected!" +button on the list page. The test then verifies that the item is marked as +collected by checking that the item's name appears as struck-through text (using +the del HTML tag) in the table cell. + +The fifth test, "Can deactivate a shopping list," verifies that a shopping list +can be deactivated by clicking on a "Deactivate list!" button on the list page. +The test then verifies that the list is deactivated by checking that the link +with the list's name is no longer present on the page. + +To run the tests, run the following commands: + +`docker-compose run --entrypoint=npx e2e-playwright playwright test && docker-compose rm -sf` + +## Local deployment + +To deploy the application locally, run `docker-compose up` in the root +directory. + +## Notes + +- When you access the application for the first time, it might display + `Internal Server Error`. This is because Fly.io will spin up a new instance of + the application for you and it needs a few seconds to start up. Subsequent + requests will be much faster. +- Playwright tests might be able to start, due to connection errors created by + Docker or Deno. If you encounter this issue, please try running it again. diff --git a/e2e-playwright/tests/hello-world.spec.js b/e2e-playwright/tests/hello-world.spec.js deleted file mode 100644 index 9dc7ee5..0000000 --- a/e2e-playwright/tests/hello-world.spec.js +++ /dev/null @@ -1,6 +0,0 @@ -const { test, expect } = require("@playwright/test"); - -test("Server responds with the text 'Hello world!'", async ({ page }) => { - const response = await page.goto("/"); - expect(await response.text()).toBe("Hello world!"); -}); \ No newline at end of file diff --git a/e2e-playwright/tests/shopper.spec.js b/e2e-playwright/tests/shopper.spec.js new file mode 100644 index 0000000..ab092a4 --- /dev/null +++ b/e2e-playwright/tests/shopper.spec.js @@ -0,0 +1,44 @@ +const { test, expect } = require("@playwright/test"); + +const name = "My first list"; + +test("Can create a new shopping list", async ({ page }) => { + await page.goto("/lists"); + await page.type("input[name=name]", name); + await page.click("input[type=submit]"); + await expect(page.locator(`a >> text='${name}'`)).toHaveText(name); +}); + +test("Can open a shopping list page", async ({ page }) => { + await page.goto("/lists"); + await page.click(`a >> text='${name}'`); + await expect(page.locator("h1")).toHaveText(name); +}); + +const item = "Milk"; + +test("Can add an item to a shopping list", async ({ page }) => { + await page.goto("/lists"); + await page.click(`a >> text='${name}'`); + await page.type("input[name=name]", item); + await page.click("input[type=submit]"); + await expect(page.locator(`td >> text='${item}'`)).toHaveText(item); +}); + +test("Can mark an item as collected", async ({ page }) => { + await page.goto("/lists"); + await page.click(`a >> text='${name}'`); + await page.click('input[value = "Mark collected!"]'); + const element = await page.locator(`td >> text='${item}'`); + const innerHTML = await page.evaluate( + (element) => element.innerHTML, + element + ); + await expect(innerHTML).toMatch(`${item}`); +}); + +test("Can deactivate a shopping list", async ({ page }) => { + await page.goto("/lists"); + await page.click('input[value = "Deactivate list!"]'); + await expect(page.locator(`a >> text='${name}'`)).not.toExist(); +}); diff --git a/shopping-lists/controllers/itemController.js b/shopping-lists/controllers/itemController.js index 59b9caa..14526c3 100644 --- a/shopping-lists/controllers/itemController.js +++ b/shopping-lists/controllers/itemController.js @@ -1,4 +1,5 @@ import * as itemService from "../services/itemService.js"; +import { viewList } from "./listController.js"; import * as requestUtils from "../utils/requestUtils.js"; const markItemCollected = async (request) => { @@ -6,7 +7,7 @@ const markItemCollected = async (request) => { const urlParts = url.pathname.split("/"); await itemService.markCollected(urlParts[4], urlParts[2]); - return requestUtils.redirectTo(`/lists/${urlParts[4]}`); + return viewList(request); }; const addItemToList = async (request) => { diff --git a/shopping-lists/controllers/listController.js b/shopping-lists/controllers/listController.js index 3d77d55..4ecd784 100644 --- a/shopping-lists/controllers/listController.js +++ b/shopping-lists/controllers/listController.js @@ -20,9 +20,21 @@ const viewList = async (request) => { const urlParts = url.pathname.split("/"); const data = { - items: await listService.findById(urlParts[2]), + flag: true, + listData: await listService.findList(urlParts[2]), + items: await listService.findItems(urlParts[2]), }; + if (data.items[0].name !== "Unknown") { + data.items.sort((a, b) => { + if (a.collected === b.collected) { + return a.name.localeCompare(b.name); + } + return a.collected ? -1 : 1; + }); + data.flag = false; + } + return new Response(await renderFile("list.eta", data), responseDetails); }; diff --git a/shopping-lists/database/database.js b/shopping-lists/database/database.js index c386f24..1ce59b6 100644 --- a/shopping-lists/database/database.js +++ b/shopping-lists/database/database.js @@ -3,7 +3,10 @@ import { Pool } from "../deps.js"; const CONCURRENT_CONNECTIONS = 2; let connectionPool; if (Deno.env.get("DATABASE_URL")) { - connectionPool = new Pool(Deno.env.get("DATABASE_URL"), CONCURRENT_CONNECTIONS); + connectionPool = new Pool( + Deno.env.get("DATABASE_URL"), + CONCURRENT_CONNECTIONS + ); } else { connectionPool = new Pool({}, CONCURRENT_CONNECTIONS); } diff --git a/shopping-lists/services/listService.js b/shopping-lists/services/listService.js index 35a8631..4949a88 100644 --- a/shopping-lists/services/listService.js +++ b/shopping-lists/services/listService.js @@ -23,11 +23,11 @@ const findAllLists = async () => { return result.rows; }; -const findById = async (shopping_list_id) => { +const findList = async (id) => { const result = await executeQuery( - "SELECT * FROM shopping_list_items WHERE shopping_list_id = $shopping_list_id;", + "SELECT * FROM shopping_lists WHERE id = $id;", { - shopping_list_id: shopping_list_id, + id: id, } ); @@ -38,4 +38,19 @@ const findById = async (shopping_list_id) => { return { id: 0, name: "Unknown" }; }; -export { deactivateByID, create, findAllLists, findById }; +const findItems = async (shopping_list_id) => { + const result = await executeQuery( + "SELECT * FROM shopping_list_items WHERE shopping_list_id = $shopping_list_id;", + { + shopping_list_id: shopping_list_id, + } + ); + + if (result.rows && result.rows.length > 0) { + return result.rows; + } + + return [{ id: 0, name: "Unknown" }]; +}; + +export { deactivateByID, create, findAllLists, findList, findItems }; diff --git a/shopping-lists/views/layouts/layout.eta b/shopping-lists/views/layouts/layout.eta index 0be3527..277bb65 100644 --- a/shopping-lists/views/layouts/layout.eta +++ b/shopping-lists/views/layouts/layout.eta @@ -2,8 +2,8 @@ Shared shopping lists - - + + diff --git a/shopping-lists/views/list.eta b/shopping-lists/views/list.eta index 07b8268..90b3344 100644 --- a/shopping-lists/views/list.eta +++ b/shopping-lists/views/list.eta @@ -1,44 +1,46 @@ <% layout("./layouts/layout.eta") %> -

<%= it.list.name %>

+ +

<%= it.listData.name %>

Add an item

-
+ Name:

Active shopping lists

- - - - - - <% - it.items.sort((a, b) => { - if (a.collected === b.collected) { - return a.name.localeCompare(b.name); - } - return a.collected ? 1 : -1; - }); - it.items.forEach((item) => { - %> - - -
ItemsCollected
- <% if (item.collected) { %> - <%= item.name %> - <% } else { %> - <%= item.name %> - <% } %> - + + +<% if (it.flag) { %> +

No items in this list yet!

+<% } else { %> + + + + + + <% it.items.forEach((item) => { %> + + <% if (item.collected) { %> + + + <% } else { %> + + + <% } %> - <% }); %> -
ItemsCollected
<%= item.name %>X<%= item.name %>
- +
+ <% }); %> +
+<% } %> -Shopping lists +Shopping lists \ No newline at end of file diff --git a/shopping-lists/views/lists.eta b/shopping-lists/views/lists.eta index d7a7359..4a4b7ba 100644 --- a/shopping-lists/views/lists.eta +++ b/shopping-lists/views/lists.eta @@ -1,5 +1,5 @@ <% layout("./layouts/layout.eta") %> -

Lists

+

Shopping lists

Add a shopping list

diff --git a/shopping-lists/views/main.eta b/shopping-lists/views/main.eta index bf5a68c..1e5dabd 100644 --- a/shopping-lists/views/main.eta +++ b/shopping-lists/views/main.eta @@ -1,5 +1,5 @@ <% layout("./layouts/layout.eta") %> -

Main page

+

Shared shopping lists