Final commit
This commit is contained in:
127
README.md
127
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.
|
||||
|
||||
@@ -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!");
|
||||
});
|
||||
44
e2e-playwright/tests/shopper.spec.js
Normal file
44
e2e-playwright/tests/shopper.spec.js
Normal 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();
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<% if (item.collected) { %>
|
||||
<del><%= item.name %></del>
|
||||
<% } else { %>
|
||||
<%= item.name %>
|
||||
<% } %>
|
||||
</th>
|
||||
<th>
|
||||
<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.forEach((item) => { %>
|
||||
<tr bgcolor="#ffffff">
|
||||
<% if (item.collected) { %>
|
||||
<td><del><%= item.name %></del></td>
|
||||
<td>X</td>
|
||||
<% } else { %>
|
||||
<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>
|
||||
<a href="/lists">Shopping lists</a>
|
||||
@@ -1,5 +1,5 @@
|
||||
<% layout("./layouts/layout.eta") %>
|
||||
<h1>Lists</h1>
|
||||
<h1>Shopping lists</h1>
|
||||
|
||||
<h2>Add a shopping list</h2>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% layout("./layouts/layout.eta") %>
|
||||
<h1>Main page</h1>
|
||||
<h1>Shared shopping lists</h1>
|
||||
|
||||
<ul>
|
||||
<% if (it.listCount !== 0) { %>
|
||||
|
||||
Reference in New Issue
Block a user