Use Autocomplete instead of Select in Add to List screen
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "sigl"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "Simple Grocery List"
|
||||
authors = ["Jonathan Krauss <jkrauss@asymworks.com>"]
|
||||
license = "BSD-3-Clause"
|
||||
|
||||
@@ -6,6 +6,7 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from sigl.exc import DomainError, NotFoundError
|
||||
@@ -44,6 +45,8 @@ def list_addItem(
|
||||
if not productName:
|
||||
raise DomainError('Product Name cannot be empty')
|
||||
|
||||
product = product_by_name(session, productName)
|
||||
if not product:
|
||||
product = Product(name=productName, category=productCategory)
|
||||
if remember is not None:
|
||||
product.remember = remember
|
||||
@@ -104,10 +107,10 @@ def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList:
|
||||
raise NotFoundError(f'List {id} does not exist')
|
||||
|
||||
for entry in sList.entries:
|
||||
if not entry.product.remember:
|
||||
session.delete(entry.product)
|
||||
if entry.crossedOff:
|
||||
session.delete(entry)
|
||||
if not entry.product.remember:
|
||||
session.delete(entry.product)
|
||||
|
||||
session.commit()
|
||||
|
||||
@@ -230,10 +233,15 @@ def products_all(session: Session) -> List[Product]:
|
||||
|
||||
|
||||
def product_by_id(session: Session, id: int) -> Optional[Product]:
|
||||
"""Load a specific Product."""
|
||||
"""Load a specific Product by Id."""
|
||||
return session.query(Product).filter(Product.id == id).one_or_none()
|
||||
|
||||
|
||||
def product_by_name(session: Session, name: str) -> Optional[Product]:
|
||||
"""Load a specific Product by Name."""
|
||||
return session.query(Product).filter(func.lower(Product.name) == func.lower(name)).one_or_none()
|
||||
|
||||
|
||||
def product_create(
|
||||
session: Session,
|
||||
name: str,
|
||||
|
||||
@@ -5,27 +5,31 @@
|
||||
<div class="text-sm font-bold text-gray-800">Add Item to {{ list.name }}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block head_scripts %}
|
||||
<script type="module">
|
||||
import JtAutocomplete from "https://unpkg.com/@jadetree/ui/dist/components/autocomplete.js";
|
||||
JtAutocomplete.register();
|
||||
</script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/@jadetree/ui/css/index.css" />
|
||||
{% endblock %}
|
||||
{% block main %}
|
||||
<form method="post">
|
||||
<div class="py-2 px-4 flex flex-col">
|
||||
<fieldset class="flex flex-col" x-data="{productId:''}">
|
||||
<fieldset class="flex flex-col" x-data="{productName:'',newProduct:false}">
|
||||
<legend class="sr-only">Select Product to Add</legend>
|
||||
<div class="flex flex-col pb-4">
|
||||
<label for="product" class="py-1 text-xs text-gray-700 font-semibold">Product:</label>
|
||||
<select id="product" name="product" class="flex-grow p-1 text-sm bg-white border rounded" x-model='productId'>
|
||||
<option value="" disabled selected>Select a Product</option>
|
||||
<option value="new">Create a New Product</option>
|
||||
{% for p in products|sort(attribute='category')|sort(attribute='name') %}
|
||||
<option value="{{ p.id }}">{{ p.name }}{% if p.category %} (in {{ p.category }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<jt-autocomplete clearable>
|
||||
<input id="product" name="productName" class="p-1 text-sm border border-gray-200 rounded" list="product-list" x-model="productName" @blur="newProduct=!isExistingProduct(productName)" />
|
||||
</jt-autocomplete>
|
||||
<span class="text-xs text-blue-300" x-show="newProduct">New Product</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between" x-show="productId == 'new'">
|
||||
<div class="w-full sm:mr-1 flex flex-col pb-4">
|
||||
<label for="productName" class="py-1 text-xs text-gray-700 font-semibold">Product Name:</label>
|
||||
<input type="text" id="productName" name="productName" class="p-1 text-sm border border-gray-200 rounded" />
|
||||
<div class="flex flex-row align-center justify-start gap-2 pb-4" x-show="newProduct">
|
||||
<input type="checkbox" id="rememberProduct" name="remember" class="appearance-none h-4 w-4 border border-gray-300 rounded-sm bg-white checked:bg-blue-600 checked:border-blue-600 focus:outline-none transition duration-200 bg-no-repeat bg-center bg-contain cursor-pointer" />
|
||||
<label for="rememberProduct" class="text-xs text-gray-700 font-semibold">Remember Product</label>
|
||||
</div>
|
||||
<div class="w-full sm:ml-1 flex flex-col pb-4">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between" x-show="newProduct">
|
||||
<div class="w-full flex flex-col pb-4">
|
||||
<label for="productCategory" class="py-1 text-xs text-gray-700 font-semibold">Category:</label>
|
||||
<input type="text" id="productCategory" name="productCategory" class="p-1 text-sm border border-gray-200 rounded" />
|
||||
</div>
|
||||
@@ -49,4 +53,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<datalist id="product-list">
|
||||
{% for p in products|sort(attribute='category')|sort(attribute='name') %}
|
||||
<option{% if p.category %} data-category="{{ p.category }}"{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
{% endblock %}
|
||||
{% block body_scripts %}
|
||||
<script language="javascript">
|
||||
function isExistingProduct(product) {
|
||||
if (!product) return true;
|
||||
const products = Array.from(document.querySelectorAll('#product-list option'))
|
||||
.map((opt) => opt.textContent.toLowerCase().trim());
|
||||
return products.includes(product.toLowerCase().trim());
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -6,6 +6,7 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
current_app,
|
||||
flash,
|
||||
jsonify,
|
||||
make_response,
|
||||
@@ -225,7 +226,7 @@ def addItem(id):
|
||||
sList = list_by_id(db.session, id)
|
||||
products = products_all(db.session)
|
||||
if request.method == 'POST':
|
||||
if 'product' not in request.form:
|
||||
if 'productName' not in request.form:
|
||||
flash(
|
||||
'An internal error occurred. Please reload the page and try again',
|
||||
'error'
|
||||
@@ -236,21 +237,20 @@ def addItem(id):
|
||||
products=products,
|
||||
)
|
||||
|
||||
productId = request.form['product']
|
||||
productName = request.form.get('productName', '').strip()
|
||||
productCategory = request.form.get('productCategory', '').strip()
|
||||
remember = request.form.get('remember', 'off') == 'on'
|
||||
quantity = request.form.get('quantity', '').strip()
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if productId == 'new' or productId == '':
|
||||
productId = None
|
||||
current_app.logger.info(f'Remember Value: {remember}')
|
||||
|
||||
list_addItem(
|
||||
db.session,
|
||||
id,
|
||||
productId=productId,
|
||||
productName=productName,
|
||||
productCategory=productCategory,
|
||||
remember=remember,
|
||||
quantity=quantity,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,12 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
from sigl.domain.service import product_by_id, product_create, products_all
|
||||
from sigl.domain.service import (
|
||||
product_by_id,
|
||||
product_by_name,
|
||||
product_create,
|
||||
products_all,
|
||||
)
|
||||
|
||||
# Always use 'app' fixture so ORM gets initialized
|
||||
pytestmark = pytest.mark.usefixtures('app')
|
||||
@@ -60,3 +65,11 @@ def test_product_all_items_skips_non_remembered(session):
|
||||
assert p1 in products
|
||||
assert p3 in products
|
||||
assert p2 not in products
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_lookup_by_name(session):
|
||||
"""Test that a Product can be looked up by Name (case-insensitive)."""
|
||||
p1 = product_create(session, 'Apples')
|
||||
product = product_by_name(session, 'apples')
|
||||
assert product == p1
|
||||
|
||||
@@ -13,6 +13,7 @@ from sigl.domain.service import (
|
||||
list_deleteCrossedOff,
|
||||
list_entry_set_crossedOff,
|
||||
product_by_id,
|
||||
product_create,
|
||||
)
|
||||
|
||||
# Always use 'app' fixture so ORM gets initialized
|
||||
@@ -45,6 +46,42 @@ def test_list_add_product_defaults(session):
|
||||
assert list.entries[0] == entry
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_add_product_by_id(session):
|
||||
"""Test adding an existing Product to a List by Id."""
|
||||
p1 = product_create(session, 'Eggs', category='Dairy')
|
||||
|
||||
list = list_create(session, 'Test')
|
||||
entry = list_addItem(session, list.id, productId=p1.id)
|
||||
|
||||
assert entry.id is not None
|
||||
assert entry.product is not None
|
||||
assert entry.product.name == 'Eggs'
|
||||
assert entry.product.category == 'Dairy'
|
||||
assert entry.product.remember is True
|
||||
|
||||
assert len(list.entries) == 1
|
||||
assert list.entries[0] == entry
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_add_product_by_name(session):
|
||||
"""Test adding an existing Product to a List by Name."""
|
||||
product_create(session, 'Eggs', category='Dairy')
|
||||
|
||||
list = list_create(session, 'Test')
|
||||
entry = list_addItem(session, list.id, productName='eggs')
|
||||
|
||||
assert entry.id is not None
|
||||
assert entry.product is not None
|
||||
assert entry.product.name == 'Eggs'
|
||||
assert entry.product.category == 'Dairy'
|
||||
assert entry.product.remember is True
|
||||
|
||||
assert len(list.entries) == 1
|
||||
assert list.entries[0] == entry
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_add_product_no_remember(session):
|
||||
"""Test adding a Product to a List without remembering it."""
|
||||
|
||||
Reference in New Issue
Block a user