feat(form): render admin orders as mobile cards (#86)
Some checks failed
Deploy / deploy (push) Has been cancelled

* feat(form): render admin orders as mobile cards

* fix(form): address mobile admin review findings

* fix(tests): remove unused mobile fixture state
This commit is contained in:
Adam Moussa 2026-07-31 10:15:43 -04:00 • committed by GitHub
parent 602b0c7fd0
commit adf175daef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 472 additions and 42 deletions

View file

@ -35,11 +35,14 @@ function setAdminActionDisabled(action, disabled) {
function loadWeekOrders(week) {
if (!week) return;
const requestId = ++latestAdminRequest;
window.FormUtils.clearStatus('admin');
currentAdminWeek = week;
editingOrder = null;
editQuantities = {};
const tbody = document.getElementById('admin-tbody');
tbody.innerHTML = '<tr><td colspan="5" class="admin-loading">Loading...</td></tr>';
const cards = document.getElementById('admin-cards');
cards.innerHTML = '<div class="admin-loading">Loading...</div>';
document.getElementById('admin-empty').classList.add('is-hidden');
document.getElementById('admin-stats').textContent = '';
@ -56,6 +59,11 @@ function loadWeekOrders(week) {
.catch(() => {
if (requestId === latestAdminRequest && week === currentAdminWeek) {
tbody.innerHTML = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
cards.innerHTML = '';
window.FormUtils.showStatus('Failed to load orders.', {
tone: 'error',
scope: 'admin',
});
}
});
}
@ -153,6 +161,116 @@ function buildViewActions(email, name) {
return wrap;
}
function formatSubmittedAt(submittedAt) {
return submittedAt
? new Date(submittedAt).toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
: '';
}
function editTotal() {
const menuPrices = {};
MEALS.forEach(m => { menuPrices[m.name] = m.price; });
return Object.keys(editQuantities).reduce((sum, name) => {
const price = menuPrices[name] || 0;
return sum + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0);
}, 0);
}
function populateOrderItems(container, order, isEditing, separatorFactory) {
if (isEditing) {
Object.keys(editQuantities).forEach(name => {
container.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
if (separatorFactory) container.appendChild(separatorFactory());
});
const availableMeals = MEALS.filter(
m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name)
);
if (availableMeals.length > 0) {
container.appendChild(buildAddItemSelect(availableMeals));
}
return '$' + editTotal().toFixed(2);
}
order.items.forEach((item, itemIdx) => {
if (separatorFactory && itemIdx > 0) {
container.appendChild(separatorFactory());
}
const row = separatorFactory ? container : document.createElement('div');
if (!separatorFactory) row.className = 'admin-item';
row.appendChild(document.createTextNode(item.name + ' ×' + item.quantity + ' '));
const sub = document.createElement('span');
sub.className = 'admin-muted';
sub.textContent = '($' + item.subtotal.toFixed(2) + ')';
row.appendChild(sub);
if (!separatorFactory) container.appendChild(row);
});
return '$' + order.total.toFixed(2);
}
function buildAdminCard(order) {
const card = document.createElement('article');
card.className = 'admin-order-card';
card.dataset.email = order.employee_email;
const isEditing = editingOrder === order.employee_email;
const header = document.createElement('div');
header.className = 'admin-card-header';
const employee = document.createElement('div');
employee.className = 'admin-card-employee';
const name = document.createElement('strong');
name.textContent = order.employee_name;
const email = document.createElement('span');
email.className = 'admin-muted';
email.textContent = order.employee_email;
employee.appendChild(name);
employee.appendChild(email);
const time = document.createElement('span');
time.className = 'admin-card-time';
time.textContent = formatSubmittedAt(order.submitted_at);
header.appendChild(employee);
header.appendChild(time);
const items = document.createElement('div');
items.className = 'admin-card-items';
const total = populateOrderItems(items, order, isEditing);
const footer = document.createElement('div');
footer.className = 'admin-card-footer';
const totalWrap = document.createElement('div');
totalWrap.className = 'admin-card-total';
const totalLabel = document.createElement('span');
totalLabel.textContent = 'Total';
const totalValue = document.createElement('strong');
totalValue.textContent = total;
totalWrap.appendChild(totalLabel);
totalWrap.appendChild(totalValue);
footer.appendChild(totalWrap);
footer.appendChild(
isEditing
? buildEditActions(order.employee_email)
: buildViewActions(order.employee_email, order.employee_name)
);
card.appendChild(header);
card.appendChild(items);
card.appendChild(footer);
return card;
}
function renderAdminCards(data) {
const cards = document.getElementById('admin-cards');
cards.innerHTML = '';
(data.orders || []).forEach(order => {
cards.appendChild(buildAdminCard(order));
});
}
function renderAdminTable(data) {
lastAdminData = data;
const hasOrders = !!(data.orders && data.orders.length > 0);
@ -160,6 +278,8 @@ function renderAdminTable(data) {
if (dlBtn) dlBtn.disabled = !hasOrders;
const tbody = document.getElementById('admin-tbody');
tbody.innerHTML = '';
const cards = document.getElementById('admin-cards');
cards.innerHTML = '';
if (!hasOrders) {
document.getElementById('admin-empty').classList.remove('is-hidden');
@ -201,39 +321,14 @@ function renderAdminTable(data) {
const itemsTd = document.createElement('td');
itemsTd.className = 'items-cell';
let totalHtml;
if (isEditing) {
const editNames = Object.keys(editQuantities);
editNames.forEach(name => {
itemsTd.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
itemsTd.appendChild(document.createElement('br'));
});
const availableMeals = MEALS.filter(m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name));
if (availableMeals.length > 0) {
itemsTd.appendChild(buildAddItemSelect(availableMeals));
}
const menuPrices = {};
MEALS.forEach(m => { menuPrices[m.name] = m.price; });
const newTotal = editNames.reduce((sum, name) => {
const price = menuPrices[name] || 0;
return sum + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0);
}, 0);
totalHtml = '$' + newTotal.toFixed(2);
tr.appendChild(empTd);
tr.appendChild(itemsTd);
} else {
order.items.forEach((item, itemIdx) => {
if (itemIdx > 0) itemsTd.appendChild(document.createElement('br'));
itemsTd.appendChild(document.createTextNode(item.name + ' ×' + item.quantity + ' '));
const sub = document.createElement('span');
sub.className = 'admin-muted';
sub.textContent = '($' + item.subtotal.toFixed(2) + ')';
itemsTd.appendChild(sub);
});
totalHtml = '$' + order.total.toFixed(2);
tr.appendChild(empTd);
tr.appendChild(itemsTd);
}
const totalHtml = populateOrderItems(
itemsTd,
order,
isEditing,
() => document.createElement('br')
);
tr.appendChild(empTd);
tr.appendChild(itemsTd);
const totalTd = document.createElement('td');
totalTd.className = 'total-cell';
@ -241,15 +336,7 @@ function renderAdminTable(data) {
const timeTd = document.createElement('td');
timeTd.className = 'time-cell';
timeTd.textContent = order.submitted_at
? new Date(order.submitted_at).toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
: '';
timeTd.textContent = formatSubmittedAt(order.submitted_at);
const actionsTd = document.createElement('td');
if (isEditing) {
@ -280,6 +367,7 @@ function renderAdminTable(data) {
totalRow.appendChild(document.createElement('td'));
totalRow.appendChild(document.createElement('td'));
tbody.appendChild(totalRow);
renderAdminCards(data);
}
function downloadOrderList() {

View file

@ -390,6 +390,8 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.edit-qty button { width: 44px; height: 44px; }
.edit-qty span { width: 44px; height: 44px; line-height: 44px; }
.btn-chip { min-height: 44px; padding: 10px 16px; }
.admin-order-card .btn,
.admin-add-item select { min-height: 44px; }
}
.form-status {
@ -512,6 +514,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
border-radius: var(--radius-card);
padding: 20px;
box-shadow: var(--shadow-card);
min-width: 0;
}
.admin-header {
@ -585,6 +588,64 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.admin-empty { text-align: center; padding: 40px 20px; color: #9ca3af; }
.admin-cards { display: none; }
.admin-order-card {
min-width: 0;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: var(--radius-card);
background: #fff;
box-shadow: var(--shadow-card);
}
.admin-card-header,
.admin-card-footer {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.admin-card-employee {
display: flex;
flex-direction: column;
min-width: 0;
overflow-wrap: anywhere;
}
.admin-card-time {
flex: 0 0 auto;
color: #9ca3af;
font-size: 0.8rem;
text-align: right;
}
.admin-card-items {
margin: 16px 0;
color: #4b5563;
font-size: 0.9rem;
min-width: 0;
overflow-wrap: anywhere;
}
.admin-item + .admin-item { margin-top: 6px; }
.admin-card-total {
display: flex;
flex-direction: column;
gap: 2px;
white-space: nowrap;
}
.admin-card-total span {
color: var(--color-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-actions { display: flex; gap: 6px; white-space: nowrap; flex-wrap: wrap; }
.btn-edit { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
@ -654,3 +715,35 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.admin-loading { color: #9ca3af; }
.admin-error { color: #991b1b; }
@media (max-width: 767px) {
.admin-panel { padding: 12px; }
.admin-table-wrap { display: none; }
.admin-cards {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.admin-card-header,
.admin-card-footer {
flex-wrap: wrap;
}
.admin-card-footer .admin-actions {
justify-content: flex-end;
}
.admin-card-items .edit-qty {
display: flex;
width: 100%;
margin-bottom: 8px;
}
.admin-card-items .edit-qty button { width: 44px; height: 44px; }
.admin-card-items .edit-qty span {
width: 44px;
height: 44px;
line-height: 44px;
}
.admin-add-item select {
width: 100%;
max-width: 100%;
}
}

View file

@ -137,6 +137,7 @@
<tbody id="admin-tbody"></tbody>
</table>
</div>
<div id="admin-cards" class="admin-cards"></div>
<div id="admin-empty" class="admin-empty is-hidden">No orders for this week.</div>
</div>
</div>

View file

@ -206,6 +206,20 @@ class TestGenerateFormStructural:
# Static markup must not interpolate runtime data into onclick
assert not re.search(r'onclick="[^"]*\$\{', html)
def test_mobile_admin_card_structure_and_breakpoint(self):
html = _render()
css = (SERVER_DIR / "templates" / "form.css").read_text()
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
assert 'id="admin-cards"' in html
assert 'id="admin-status-alert"' in html
assert 'id="admin-status-info"' in html
assert "@media (max-width: 767px)" in css
assert ".admin-table-wrap { display: none; }" in css
assert "buildAdminCard" in admin_js
assert "dataset.adminAction" in admin_js
assert "confirm(" in admin_js
def test_script_breakout_meal_escaped(self):
html = _render()
assert "</script><script>alert(1)</script>" not in html
@ -584,6 +598,240 @@ class TestGenerateFormPlaywright:
)
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
@pytest.fixture
def mobile_admin_page(self, tmp_path, browser_page):
html = _render(google=False, api_key="")
out = tmp_path / "admin-order-form.html"
out.write_text(html)
hostile_name = """O'Reilly "<b>Ops</b>\""""
state = {
"fail_put": False,
"dialogs": [],
"orders": [
{
"employee_name": hostile_name,
"employee_email": "hostile@example.com",
"submitted_at": "2026-07-30T14:15:00Z",
"items": [
{
"name": "Chicken Bowl",
"quantity": 1,
"subtotal": 12.0,
}
],
"total": 12.0,
}
],
}
prices = {
"Chicken Bowl": 12.0,
"Luis' Lomo Saltado": 14.5,
'Meal with "quotes"': 11.0,
"</script><script>alert(1)</script>": 9.0,
}
def admin_payload():
return {
"week": "2026-W30",
"orders": state["orders"],
"total_employees": len(state["orders"]),
"grand_total": sum(order["total"] for order in state["orders"]),
}
def handle_admin(route):
request = route.request
if request.method == "PUT":
if state["fail_put"]:
route.fulfill(
status=500,
content_type="application/json",
body='{"error":"hostile update failed"}',
)
return
payload = request.post_data_json
order = state["orders"][0]
order["items"] = [
{
"name": item["name"],
"quantity": item["quantity"],
"subtotal": prices[item["name"]] * item["quantity"],
}
for item in payload["items"]
]
order["total"] = sum(item["subtotal"] for item in order["items"])
route.fulfill(
status=200,
content_type="application/json",
body='{"ok":true}',
)
return
if request.method == "DELETE":
state["orders"] = []
route.fulfill(
status=200,
content_type="application/json",
body='{"ok":true}',
)
return
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(admin_payload()),
)
browser = browser_page.context.browser
assert browser is not None
context = browser.new_context(
viewport={"width": 375, "height": 800},
has_touch=True,
is_mobile=True,
)
try:
page = context.new_page()
page.route(
"**/api/form-status/**",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"status":"open"}',
),
)
page.route(
"**/api/roster**",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"employees":[]}',
),
)
page.route("**/api/admin/orders**", handle_admin)
page.on(
"dialog",
lambda dialog: (
state["dialogs"].append(dialog.message),
dialog.accept()
if state.get("accept_dialogs", True)
else dialog.dismiss(),
),
)
page.goto(out.as_uri())
page.evaluate(
"() => { window.showAdmin(); window.loadWeekOrders('2026-W30'); }"
)
page.wait_for_selector(".admin-order-card")
yield page, state, hostile_name
finally:
context.close()
def test_mobile_admin_card_initial_render_and_overflow(self, mobile_admin_page):
page, _, hostile_name = mobile_admin_page
card = page.locator(".admin-order-card")
assert card.is_visible()
assert not page.locator(".admin-table-wrap").is_visible()
assert hostile_name in card.inner_text()
assert "hostile@example.com" in card.inner_text()
assert "Chicken Bowl ×1" in card.inner_text() # noqa: RUF001
assert "$12.00" in card.inner_text()
assert page.evaluate(
"() => document.documentElement.scrollWidth === "
"document.documentElement.clientWidth"
)
def test_mobile_admin_card_edit_and_save(self, mobile_admin_page):
page, state, hostile_name = mobile_admin_page
card = page.locator(".admin-order-card")
card.locator('[data-admin-action="start-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]')
minus = page.locator(
'.admin-order-card [data-admin-action="edit-qty"][data-delta="-1"]'
)
assert minus.get_attribute("aria-label") == "Remove one Chicken Bowl"
assert minus.evaluate("(el) => el.getBoundingClientRect().height") >= 44
add_item = page.locator(
'.admin-order-card [data-admin-action="add-item-select"]'
)
add_item.select_option(label="Luis' Lomo Saltado ($14.50)")
page.wait_for_selector(
'.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"]'
)
page.locator(
'.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"][data-delta="1"]'
).click()
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_function(
"() => document.querySelector('.admin-order-card')"
'.textContent.includes("Luis\' Lomo Saltado ×2")' # noqa: RUF001
)
assert state["orders"][0]["employee_name"] == hostile_name
assert state["orders"][0]["total"] == 41.0
def test_mobile_admin_card_cancel(self, mobile_admin_page):
page, _, _ = mobile_admin_page
page.locator('.admin-order-card [data-admin-action="start-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="cancel-edit"]')
page.locator(
'.admin-order-card [data-admin-action="edit-qty"][data-delta="1"]'
).first.click()
page.locator('.admin-order-card [data-admin-action="cancel-edit"]').click()
assert page.locator(
'.admin-order-card [data-admin-action="start-edit"]'
).is_visible()
def test_mobile_admin_card_responsive_breakpoints(self, mobile_admin_page):
page, _, _ = mobile_admin_page
for width in (320, 375, 767):
page.set_viewport_size({"width": width, "height": 800})
assert page.locator(".admin-order-card").is_visible()
assert page.evaluate(
"() => document.documentElement.scrollWidth === "
"document.documentElement.clientWidth"
)
assert page.evaluate(
"""() => Array.from(document.querySelectorAll('#admin-panel *'))
.filter((el) => el.offsetParent !== null)
.every((el) => el.scrollWidth <= el.clientWidth + 1)"""
)
page.set_viewport_size({"width": 768, "height": 800})
assert page.locator(".admin-table-wrap").is_visible()
assert not page.locator(".admin-order-card").is_visible()
def test_mobile_admin_card_save_failure_and_retry(self, mobile_admin_page):
page, state, _ = mobile_admin_page
state["fail_put"] = True
page.locator('.admin-order-card [data-admin-action="start-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]')
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_function(
"() => document.getElementById('admin-status-alert').textContent"
".includes('hostile update failed')"
)
state["fail_put"] = False
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="start-edit"]')
assert page.locator("#admin-status-alert").inner_text() == ""
def test_mobile_admin_card_delete(self, mobile_admin_page):
page, state, hostile_name = mobile_admin_page
orders_before = list(state["orders"])
state["accept_dialogs"] = False
page.locator('.admin-order-card [data-admin-action="delete"]').click()
assert page.locator(".admin-order-card").is_visible()
assert state["orders"] == orders_before
state["accept_dialogs"] = True
page.locator('.admin-order-card [data-admin-action="delete"]').click()
page.wait_for_selector("#admin-empty:not(.is-hidden)")
assert hostile_name in state["dialogs"][-1]
assert state["orders"] == []
class TestGenerateFormGooglePlaywright:
@pytest.fixture(scope="class")