Final commit

This commit is contained in:
AndrewTrieu
2022-12-28 18:42:13 +02:00
parent 08128374c1
commit 2a37c8bdf4
11 changed files with 243 additions and 47 deletions

127
README.md
View File

@@ -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.

View File

@@ -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!");
});

View File

@@ -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(`<del>${item}</del>`);
});
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();
});

View File

@@ -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) => {

View File

@@ -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);
};

View File

@@ -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);
}

View File

@@ -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 };

View File

@@ -2,8 +2,8 @@
<html lang="en">
<head>
<title>Shared shopping lists</title>
<link rel="stylesheet" href="https://unpkg.com/papercss@1.8.1/dist/paper.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1>
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta charset="utf-8">
</head>

View File

@@ -1,44 +1,46 @@
<% layout("./layouts/layout.eta") %>
<h1><%= it.list.name %></h1>
<h1><%= it.listData.name %></h1>
<h2>Add an item</h2>
<form method="POST" action="/lists/<%= it.list.id %>/items">
<form method="POST" action="/lists/<%= it.listData.id %>/items">
Name: <input type="text" name="name" />
<input type="submit" value="Add!" />
</form>
<h2>Active shopping lists</h2>
<table>
<tr>
<style>
td, th {
text-align: center;
}
</style>
<% if (it.flag) { %>
<p>No items in this list yet!</p>
<% } else { %>
<table cellspacing="3" bgcolor="#000000">
<tr bgcolor="#ffffff">
<th>Items</th>
<th>Collected</th>
</tr>
<%
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) => {
%>
<tr>
<th>
<% it.items.forEach((item) => { %>
<tr bgcolor="#ffffff">
<% if (item.collected) { %>
<del><%= item.name %></del>
<td><del><%= item.name %></del></td>
<td>X</td>
<% } else { %>
<%= item.name %>
<% } %>
</th>
<th>
<td><%= item.name %></td>
<td>
<form method="POST" action="/lists/<%= item.shopping_list_id %>/items/<%= item.id %>/collect">
<input type="submit" value="Mark collected!" />
</form>
</th>
</td>
<% } %>
</tr>
<% }); %>
</table>
</table>
<% } %>
<a href="/lists">Shopping lists</a>

View File

@@ -1,5 +1,5 @@
<% layout("./layouts/layout.eta") %>
<h1>Lists</h1>
<h1>Shopping lists</h1>
<h2>Add a shopping list</h2>

View File

@@ -1,5 +1,5 @@
<% layout("./layouts/layout.eta") %>
<h1>Main page</h1>
<h1>Shared shopping lists</h1>
<ul>
<% if (it.listCount !== 0) { %>