From 02fe514712f83c148bfefe6dbb9911ac9fbfadf2 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 30 Jul 2026 17:30:07 -0400 Subject: [PATCH] feat(form): render admin orders as mobile cards --- src/server/templates/admin.js | 197 +++++++++++++++++++++----- src/server/templates/form.css | 104 ++++++++++++++ src/server/templates/form.html.j2 | 5 + tests/test_generate_form.py | 223 ++++++++++++++++++++++++++++++ 4 files changed, 493 insertions(+), 36 deletions(-) diff --git a/src/server/templates/admin.js b/src/server/templates/admin.js index b835ad6..5d821b6 100644 --- a/src/server/templates/admin.js +++ b/src/server/templates/admin.js @@ -1,14 +1,26 @@ /* ADMIN_JS_MARKER */ -const ADMIN_URL = window.ADMIN_URL; -const ADMIN_PDF_URL = window.ADMIN_PDF_URL; -const MEALS = window.MEALS; -const WEEK = window.WEEK; +const ADMIN_API_URL = window.ADMIN_URL; +const ADMIN_SUMMARY_PDF_URL = window.ADMIN_PDF_URL; +const ADMIN_MEALS = window.MEALS; +const ADMIN_WEEK = window.WEEK; let currentAdminWeek = ''; let editingOrder = null; let editQuantities = {}; let lastAdminData = null; +function showAdminStatus(message, tone = 'error') { + const alertEl = document.getElementById('admin-alert'); + const statusEl = document.getElementById('admin-status'); + if (!alertEl || !statusEl) return; + alertEl.textContent = tone === 'error' ? message : ''; + statusEl.textContent = tone === 'error' ? '' : message; +} + +function clearAdminStatus() { + showAdminStatus('', 'status'); +} + function adminHeaders() { const h = {}; const cred = window.googleCredential; @@ -18,19 +30,24 @@ function adminHeaders() { function loadWeekOrders(week) { if (!week) return; + clearAdminStatus(); 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 = ''; - fetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() }) + fetch(ADMIN_API_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() }) .then(r => r.json()) .then(data => renderAdminTable(data)) .catch(() => { tbody.innerHTML = 'Failed to load orders.'; + cards.innerHTML = ''; + showAdminStatus('Failed to load orders.'); }); } @@ -46,9 +63,11 @@ function buildEditQtyRow(name, qty) { minusBtn.dataset.mealName = name; minusBtn.dataset.delta = '-1'; minusBtn.innerHTML = '−'; + minusBtn.setAttribute('aria-label', 'Remove one ' + name); const span = document.createElement('span'); span.textContent = qty; + span.setAttribute('aria-label', 'Quantity of ' + name); const plusBtn = document.createElement('button'); plusBtn.type = 'button'; @@ -56,6 +75,7 @@ function buildEditQtyRow(name, qty) { plusBtn.dataset.mealName = name; plusBtn.dataset.delta = '1'; plusBtn.textContent = '+'; + plusBtn.setAttribute('aria-label', 'Add one ' + name); wrap.appendChild(minusBtn); wrap.appendChild(span); @@ -123,6 +143,112 @@ function buildViewActions(idx, 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 = {}; + ADMIN_MEALS.forEach(m => { menuPrices[m.name] = m.price; }); + return Object.keys(editQuantities).reduce((sum, name) => { + const price = menuPrices[name] || 0; + return sum + price * (editQuantities[name] || 0); + }, 0); +} + +function populateOrderItems(container, order, isEditing) { + if (isEditing) { + Object.keys(editQuantities).forEach(name => { + container.appendChild(buildEditQtyRow(name, editQuantities[name] || 0)); + }); + const availableMeals = ADMIN_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 => { + const row = document.createElement('div'); + 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); + container.appendChild(row); + }); + return '$' + order.total.toFixed(2); +} + +function buildAdminCard(order, idx) { + 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(idx, 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, idx) => { + cards.appendChild(buildAdminCard(order, idx)); + }); +} + function renderAdminTable(data) { lastAdminData = data; const hasOrders = !!(data.orders && data.orders.length > 0); @@ -130,6 +256,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'); @@ -178,17 +306,11 @@ function renderAdminTable(data) { itemsTd.appendChild(buildEditQtyRow(name, editQuantities[name] || 0)); itemsTd.appendChild(document.createElement('br')); }); - const availableMeals = MEALS.filter(m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name)); + const availableMeals = ADMIN_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 + price * (editQuantities[name] || 0); - }, 0); - totalHtml = '$' + newTotal.toFixed(2); + totalHtml = '$' + editTotal().toFixed(2); tr.appendChild(empTd); tr.appendChild(itemsTd); } else { @@ -211,15 +333,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) { @@ -250,6 +364,7 @@ function renderAdminTable(data) { totalRow.appendChild(document.createElement('td')); totalRow.appendChild(document.createElement('td')); tbody.appendChild(totalRow); + renderAdminCards(data); } function downloadOrderList() { @@ -267,7 +382,7 @@ function downloadOrderList() { }; const rows = [['Item', 'Quantity']].concat(names.map(n => [n, totals[n]])); const csv = rows.map(r => r.map(esc).join(',')).join('\r\n'); - const week = data.week || currentAdminWeek || WEEK; + const week = data.week || currentAdminWeek || ADMIN_WEEK; const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -280,10 +395,11 @@ function downloadOrderList() { } function downloadSummaryPdf() { - const week = currentAdminWeek || WEEK; + clearAdminStatus(); + const week = currentAdminWeek || ADMIN_WEEK; const btn = document.getElementById('admin-pdf-btn'); if (btn) btn.disabled = true; - fetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() }) + fetch(ADMIN_SUMMARY_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() }) .then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data }))) .then(({ ok, status, data }) => { if (ok && data.url) { @@ -291,21 +407,27 @@ function downloadSummaryPdf() { if (typeof data.url === 'string' && data.url.startsWith('https://')) { window.location.href = data.url; } else { - alert('Could not fetch the summary PDF: invalid URL'); + showAdminStatus('Could not fetch the summary PDF: invalid URL'); } } else if (status === 404) { - alert('No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.'); + showAdminStatus( + 'No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.' + ); } else { - alert('Could not fetch the summary PDF: ' + (data.error || 'unknown error')); + showAdminStatus('Could not fetch the summary PDF: ' + (data.error || 'unknown error')); } }) - .catch(() => alert('Could not fetch the summary PDF.')) + .catch(() => showAdminStatus('Could not fetch the summary PDF.')) .finally(() => { if (btn) btn.disabled = false; }); } function adminStartEdit(idx, email) { - fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() }) - .then(r => r.json()) + clearAdminStatus(); + fetch(ADMIN_API_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() }) + .then(r => { + if (!r.ok) throw new Error('Could not load this order.'); + return r.json(); + }) .then(data => { lastAdminData = data; const order = data.orders.find(o => o.employee_email === email); @@ -314,7 +436,8 @@ function adminStartEdit(idx, email) { editQuantities = {}; order.items.forEach(i => { editQuantities[i.name] = i.quantity; }); renderAdminTable(data); - }); + }) + .catch(e => showAdminStatus(e.message || 'Could not load this order.')); } function adminCancelEdit() { @@ -336,6 +459,7 @@ function adminAddItem(name) { } function adminSaveEdit(email) { + clearAdminStatus(); const items = Object.entries(editQuantities) .filter(([, q]) => q > 0) .map(([name, quantity]) => ({ name, quantity })); @@ -345,26 +469,27 @@ function adminSaveEdit(email) { } return; } - fetch(ADMIN_URL, { + fetch(ADMIN_API_URL, { method: 'PUT', headers: { ...adminHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ week: currentAdminWeek, email, items }), }) .then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); }) .then(() => { editingOrder = null; editQuantities = {}; loadWeekOrders(currentAdminWeek); }) - .catch(e => alert('Failed to update: ' + e.message)); + .catch(e => showAdminStatus('Failed to update: ' + e.message)); } function adminDeleteOrder(email, name) { const label = name ? name + ' (' + email + ')' : email; if (!confirm('Delete order for ' + label + '?')) return; - fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), { + clearAdminStatus(); + fetch(ADMIN_API_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), { method: 'DELETE', headers: adminHeaders(), }) .then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); }) .then(() => loadWeekOrders(currentAdminWeek)) - .catch(e => alert('Failed to delete: ' + e.message)); + .catch(e => showAdminStatus('Failed to delete: ' + e.message)); } function handleAdminPanelClick(e) { diff --git a/src/server/templates/form.css b/src/server/templates/form.css index b4c73b7..65e9823 100644 --- a/src/server/templates/form.css +++ b/src/server/templates/form.css @@ -355,6 +355,8 @@ header p { font-size: 0.85rem; opacity: 0.8; } .qty-control .qty { width: 44px; height: 44px; } .edit-qty button { width: 44px; height: 44px; } .edit-qty span { width: 44px; height: 44px; line-height: 44px; } + .admin-order-card .btn, + .admin-add-item select { min-height: 44px; } } .discount-banner { @@ -442,6 +444,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 { @@ -479,6 +482,17 @@ header p { font-size: 0.85rem; opacity: 0.8; } .admin-stats { font-size: 0.9rem; color: var(--color-muted); margin-bottom: 16px; } .admin-stats strong { color: #1d1d1f; } +.admin-status-regions:empty { display: none; } + +.admin-status { + margin-bottom: 12px; + font-size: 0.9rem; + overflow-wrap: anywhere; +} + +.admin-status:empty { display: none; } +.admin-status-error { color: #991b1b; } + .admin-table-wrap { overflow-x: auto; } .admin-table { @@ -515,6 +529,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; } @@ -584,3 +656,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 905eb91..7b48bf1 100644 --- a/src/server/templates/form.html.j2 +++ b/src/server/templates/form.html.j2 @@ -115,6 +115,10 @@
+
+ +
+
@@ -129,6 +133,7 @@
+
diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py index 7930998..98f3de0 100644 --- a/tests/test_generate_form.py +++ b/tests/test_generate_form.py @@ -142,6 +142,23 @@ 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-alert"' in html + assert 'role="alert"' in html + assert 'id="admin-status"' in html + assert 'role="status"' 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 "alert(" not in admin_js + assert "confirm(" in admin_js + def test_script_breakout_meal_escaped(self): html = _render() assert "" not in html @@ -302,3 +319,209 @@ class TestGenerateFormPlaywright: "() => document.getElementById('count-display').textContent === '2 meals'" ) assert page.locator("#total-display").inner_text() == "$10.80" + + @pytest.fixture(scope="class") + def mobile_admin_page(self, tmp_path_factory, browser_page): + html = _render(google=False, api_key="") + out = tmp_path_factory.mktemp("admin-form") / "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()), + ) + + context = browser_page.context.browser.new_context( + viewport={"width": 375, "height": 800}, + has_touch=True, + is_mobile=True, + ) + 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(), + ), + ) + 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 + context.close() + + def test_mobile_admin_card_complete_workflow(self, mobile_admin_page): + page, state, 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() + assert "$12.00" in card.inner_text() + assert page.evaluate( + "() => document.documentElement.scrollWidth === " + "document.documentElement.clientWidth" + ) + + 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")' + ) + assert state["orders"][0]["employee_name"] == hostile_name + assert state["orders"][0]["total"] == 41.0 + + 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() + + 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() + + page.set_viewport_size({"width": 375, "height": 800}) + 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-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-alert").inner_text() == "" + + 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"] == []