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 @@
| Items | -Collected | -||||||
|---|---|---|---|---|---|---|---|
|
- <% if (item.collected) { %>
- |
-
+
+
+<% if (it.flag) { %>
+ No items in this list yet! +<% } else { %> +
|