From adf175daefd03033516dd9c2bae5b66202d68044 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:15:43 -0400 Subject: [PATCH] feat(form): render admin orders as mobile cards (#86) * feat(form): render admin orders as mobile cards * fix(form): address mobile admin review findings * fix(tests): remove unused mobile fixture state --- src/server/templates/admin.js | 172 ++++++++++++++++----- src/server/templates/form.css | 93 +++++++++++ src/server/templates/form.html.j2 | 1 + tests/test_generate_form.py | 248 ++++++++++++++++++++++++++++++ 4 files changed, 472 insertions(+), 42 deletions(-) diff --git a/src/server/templates/admin.js b/src/server/templates/admin.js index c7470c8..897a5da 100644 --- a/src/server/templates/admin.js +++ b/src/server/templates/admin.js @@ -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 = 'Loading...'; + const cards = document.getElementById('admin-cards'); + cards.innerHTML = '
Loading...
'; 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 = 'Failed to load orders.'; + 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() { diff --git a/src/server/templates/form.css b/src/server/templates/form.css index 0e35cd9..a7d832c 100644 --- a/src/server/templates/form.css +++ b/src/server/templates/form.css @@ -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%; + } +} diff --git a/src/server/templates/form.html.j2 b/src/server/templates/form.html.j2 index 0f1a324..f9510cb 100644 --- a/src/server/templates/form.html.j2 +++ b/src/server/templates/form.html.j2 @@ -137,6 +137,7 @@ +
diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py index 08a64c8..056b3f3 100644 --- a/tests/test_generate_form.py +++ b/tests/test_generate_form.py @@ -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 "" 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 "Ops\"""" + 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, + "": 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")