482 lines
18 KiB
Markdown
482 lines
18 KiB
Markdown
---
|
|
phase: 11-ui-enhancements
|
|
plan: "02"
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- "11-01"
|
|
files_modified:
|
|
- imptune/api/printers.py
|
|
- imptune/templates/partials/printer_list.html
|
|
- imptune/templates/partials/printer_edit_modal.html
|
|
- tests/e2e/test_printer_edit.py
|
|
autonomous: true
|
|
requirements:
|
|
- UIE-01
|
|
must_haves:
|
|
truths:
|
|
- "Every printer row in the list has an Edit button next to the Delete button"
|
|
- "Clicking Edit opens a pre-filled native <dialog> modal for that printer"
|
|
- "Submitting the edit form sends HTMX PATCH to /printers/{id} and refreshes the printer list in-place"
|
|
- "PATCH /printers/{id} returns 200 with the updated printer list partial"
|
|
- "PATCH /printers/9999 returns 404"
|
|
artifacts:
|
|
- path: "imptune/templates/partials/printer_edit_modal.html"
|
|
provides: "Edit modal template with pre-filled fields and PATCH form"
|
|
min_lines: 40
|
|
- path: "imptune/api/printers.py"
|
|
provides: "PATCH /printers/{id} route handler"
|
|
contains: "@router.patch"
|
|
- path: "tests/e2e/test_printer_edit.py"
|
|
provides: "E2E test: modal open, pre-fill verification, submit, list update"
|
|
min_lines: 20
|
|
key_links:
|
|
- from: "imptune/templates/partials/printer_list.html"
|
|
to: "printer_edit_modal.html"
|
|
via: "{% include %} inside {% for p in printers %} loop"
|
|
pattern: "include.*printer_edit_modal"
|
|
- from: "printer_edit_modal.html"
|
|
to: "PATCH /printers/{id}"
|
|
via: "hx-patch attribute on the edit form"
|
|
pattern: "hx-patch"
|
|
- from: "imptune/api/printers.py update_printer"
|
|
to: "_render_printer_list"
|
|
via: "return _render_printer_list(request) on success"
|
|
pattern: "_render_printer_list"
|
|
---
|
|
|
|
<objective>
|
|
Add the printer edit modal — Edit button per row, native dialog, HTMX PATCH handler, in-place list refresh.
|
|
|
|
Purpose: UIE-01 — Users need to fix printer details (wrong IP, changed driver) without deleting and recreating. A lightweight in-place edit flow covers the daily need.
|
|
|
|
Output: PATCH /printers/{id} route, printer_edit_modal.html partial, updated printer_list.html with Edit button, E2E test.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
|
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/phases/11-ui-enhancements/11-CONTEXT.md
|
|
@.planning/phases/11-ui-enhancements/11-RESEARCH.md
|
|
@.planning/phases/11-ui-enhancements/11-01-SUMMARY.md
|
|
|
|
<interfaces>
|
|
<!-- Key patterns extracted from codebase. Executor uses these directly. -->
|
|
|
|
From imptune/api/printers.py (existing helpers — reuse unchanged):
|
|
```python
|
|
_VALID_DUPLEX = {"OneSided", "LongEdge", "ShortEdge"}
|
|
_VALID_PAPER = {"A4", "Letter", "Legal"}
|
|
|
|
def _error_response(message: str, status_code: int = 400) -> HTMLResponse: ...
|
|
def _render_printer_list(request: Request) -> HTMLResponse: ...
|
|
|
|
# existing routes: POST "", DELETE "/{printer_id}"
|
|
# ADD: PATCH "/{printer_id}"
|
|
```
|
|
|
|
From imptune/db/models.py — Printer fields for pre-fill:
|
|
```python
|
|
class Printer(BaseModel):
|
|
name = CharField()
|
|
ip_address = CharField()
|
|
port_name = CharField()
|
|
client = ForeignKeyField(Client, null=True)
|
|
driver = ForeignKeyField(Driver, null=True)
|
|
duplex_mode = CharField(default="OneSided") # "OneSided" | "LongEdge" | "ShortEdge"
|
|
color_mode = BooleanField(default=True)
|
|
paper_size = CharField(default="A4") # "A4" | "Letter" | "Legal"
|
|
collate = BooleanField(default=True)
|
|
updated_at = DateTimeField(default=_utcnow) # MUST be set explicitly on update
|
|
```
|
|
|
|
From imptune/templates/partials/printer_list.html — current Actions cell (to be updated):
|
|
```html
|
|
<td>
|
|
<button
|
|
hx-delete="/printers/{{ p.id }}"
|
|
hx-target="#printer-list"
|
|
hx-swap="outerHTML"
|
|
hx-confirm="Delete '{{ p.name }}'?">
|
|
Delete
|
|
</button>
|
|
</td>
|
|
```
|
|
|
|
Pico CSS dialog pattern (from RESEARCH.md):
|
|
```html
|
|
<dialog id="edit-modal-{{ p.id }}">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev"
|
|
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"></button>
|
|
<h3>Edit Printer</h3>
|
|
</header>
|
|
<!-- form content -->
|
|
</article>
|
|
</dialog>
|
|
```
|
|
|
|
HTMX PATCH + close after success (from RESEARCH.md):
|
|
```html
|
|
<form hx-patch="/printers/{{ p.id }}"
|
|
hx-target="#printer-list"
|
|
hx-swap="outerHTML"
|
|
hx-on::after-request="document.getElementById('edit-modal-{{ p.id }}').close()">
|
|
```
|
|
|
|
Alpine.js portEdited in edit mode — must be TRUE (not false) so editing IP does not overwrite a manually set port:
|
|
```html
|
|
<div x-data="{ ip: '{{ p.ip_address }}', port: '{{ p.port_name }}', portEdited: true }">
|
|
```
|
|
|
|
From tests/e2e/conftest.py — live_server fixture already available (session-scoped).
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: PATCH /printers/{id} route handler</name>
|
|
<files>imptune/api/printers.py</files>
|
|
<behavior>
|
|
- PATCH /printers/{id} with valid fields returns 200 and HTML containing the updated printer name
|
|
- DB record is updated: Printer.get_by_id(id).name == new_name
|
|
- PATCH /printers/{id} with updated_at is set (not creation time) after update
|
|
- PATCH /printers/9999 returns 404
|
|
- PATCH /printers/{id} with empty name returns 400
|
|
These tests already exist as RED scaffolds from Plan 01 (test_patch_printer, test_patch_printer_not_found)
|
|
</behavior>
|
|
<action>
|
|
Add a PATCH route to imptune/api/printers.py immediately after the DELETE route.
|
|
|
|
Import addition at top of file:
|
|
```python
|
|
from imptune.db.models import Client, Driver, Printer
|
|
```
|
|
(Client and Driver may need to be added if not already imported — check existing imports first)
|
|
|
|
Add this handler:
|
|
```python
|
|
@router.patch("/{printer_id}", response_class=HTMLResponse)
|
|
def update_printer(
|
|
request: Request,
|
|
printer_id: int,
|
|
name: str = Form(...),
|
|
ip_address: str = Form(...),
|
|
port_name: str = Form(...),
|
|
duplex_mode: str = Form("OneSided"),
|
|
color_mode: str = Form(""),
|
|
paper_size: str = Form("A4"),
|
|
collate: str = Form(""),
|
|
client_id: str = Form(""),
|
|
driver_id: str = Form(""),
|
|
) -> HTMLResponse:
|
|
"""Update an existing printer configuration in-place."""
|
|
from imptune.db.models import Printer
|
|
from datetime import UTC
|
|
from datetime import datetime
|
|
|
|
printer = Printer.get_or_none(Printer.id == printer_id)
|
|
if printer is None:
|
|
return _error_response(f"Printer {printer_id} not found.", status_code=404)
|
|
|
|
name = name.strip()
|
|
ip_address = ip_address.strip()
|
|
port_name = port_name.strip()
|
|
|
|
if not name:
|
|
return _error_response("Printer name is required.")
|
|
if not ip_address:
|
|
return _error_response("IP address is required.")
|
|
if not port_name:
|
|
return _error_response("Port name is required.")
|
|
if duplex_mode not in _VALID_DUPLEX:
|
|
return _error_response(f"Invalid duplex mode: {duplex_mode}.")
|
|
if paper_size not in _VALID_PAPER:
|
|
return _error_response(f"Invalid paper size: {paper_size}.")
|
|
|
|
printer.name = name
|
|
printer.ip_address = ip_address
|
|
printer.port_name = port_name
|
|
printer.duplex_mode = duplex_mode
|
|
printer.color_mode = color_mode == "on"
|
|
printer.paper_size = paper_size
|
|
printer.collate = collate == "on"
|
|
printer.client = int(client_id) if client_id.strip() else None
|
|
printer.driver = int(driver_id) if driver_id.strip() else None
|
|
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
|
printer.save()
|
|
|
|
return _render_printer_list(request)
|
|
```
|
|
|
|
Do NOT import datetime at module level if it conflicts with existing imports — use local import inside the function as shown.
|
|
</action>
|
|
<verify>
|
|
<automated>cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_printer_crud.py -x -q -k "patch_printer" 2>&1 | tail -10</automated>
|
|
</verify>
|
|
<done>test_patch_printer and test_patch_printer_not_found both GREEN; existing DELETE tests still pass.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Edit button, edit modal partial, and E2E test</name>
|
|
<files>
|
|
imptune/templates/partials/printer_list.html,
|
|
imptune/templates/partials/printer_edit_modal.html,
|
|
tests/e2e/test_printer_edit.py
|
|
</files>
|
|
<action>
|
|
**Step 1 — Create imptune/templates/partials/printer_edit_modal.html:**
|
|
|
|
This partial is included once per printer row (inside the {% for p in printers %} loop in printer_list.html). It renders the edit dialog AND the Edit trigger button.
|
|
|
|
Structure (follow the Pico CSS dialog pattern from RESEARCH.md):
|
|
```html
|
|
<!-- Edit trigger button — placed in Actions column -->
|
|
<button class="secondary outline"
|
|
onclick="document.getElementById('edit-modal-{{ p.id }}').showModal()">
|
|
Edit
|
|
</button>
|
|
|
|
<!-- Edit dialog — Pico CSS native dialog, no extra library -->
|
|
<dialog id="edit-modal-{{ p.id }}">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev"
|
|
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"></button>
|
|
<h3>Edit Printer</h3>
|
|
</header>
|
|
<div x-data="{ ip: '{{ p.ip_address }}', port: '{{ p.port_name }}', portEdited: true }">
|
|
<form hx-patch="/printers/{{ p.id }}"
|
|
hx-target="#printer-list"
|
|
hx-swap="outerHTML"
|
|
hx-on::after-request="document.getElementById('edit-modal-{{ p.id }}').close()">
|
|
|
|
<label>Printer Name
|
|
<input type="text" name="name" value="{{ p.name }}" required>
|
|
</label>
|
|
|
|
<label>IP Address
|
|
<input type="text" name="ip_address"
|
|
x-model="ip"
|
|
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
|
required>
|
|
</label>
|
|
|
|
<label>Port Name
|
|
<input type="text" name="port_name"
|
|
x-model="port"
|
|
@change="portEdited = true"
|
|
@keydown="portEdited = true">
|
|
</label>
|
|
|
|
<label>Driver
|
|
<select name="driver_id">
|
|
<option value="">-- No driver --</option>
|
|
{% for item in driver_data %}
|
|
<option value="{{ item.driver.id }}"
|
|
{% if p.driver_id == item.driver.id %}selected{% endif %}>
|
|
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
|
|
<label>Duplex Mode
|
|
<select name="duplex_mode">
|
|
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %}>One-Sided</option>
|
|
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %}>Long Edge</option>
|
|
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %}>Short Edge</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>
|
|
<input type="checkbox" name="color_mode" value="on"
|
|
{% if p.color_mode %}checked{% endif %}>
|
|
Color Mode
|
|
</label>
|
|
|
|
<label>Paper Size
|
|
<select name="paper_size">
|
|
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
|
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
|
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>
|
|
<input type="checkbox" name="collate" value="on"
|
|
{% if p.collate %}checked{% endif %}>
|
|
Collate
|
|
</label>
|
|
|
|
<label>Client
|
|
<select name="client_id">
|
|
<option value="">-- Unassigned --</option>
|
|
{% for c in clients %}
|
|
<option value="{{ c.id }}"
|
|
{% if p.client_id == c.id %}selected{% endif %}>
|
|
{{ c.name }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
|
|
<footer>
|
|
<button type="submit">Save</button>
|
|
<button type="button" class="secondary"
|
|
onclick="document.getElementById('edit-modal-{{ p.id }}').close()">
|
|
Cancel
|
|
</button>
|
|
</footer>
|
|
</form>
|
|
</div>
|
|
</article>
|
|
</dialog>
|
|
```
|
|
|
|
Note: `clients` and `driver_data` context variables are already passed to printer_list.html via _render_printer_list — verify this. If _render_printer_list does NOT pass clients, update it to include `clients = list(Client.select().order_by(Client.name))` in the context. Check imptune/api/printers.py _render_printer_list to confirm.
|
|
|
|
**Step 2 — Update imptune/templates/partials/printer_list.html:**
|
|
|
|
In the Actions `<td>`, include the edit modal partial:
|
|
```html
|
|
<td>
|
|
{% include "partials/printer_edit_modal.html" %}
|
|
<button
|
|
hx-delete="/printers/{{ p.id }}"
|
|
hx-target="#printer-list"
|
|
hx-swap="outerHTML"
|
|
hx-confirm="Delete '{{ p.name }}'?">
|
|
Delete
|
|
</button>
|
|
</td>
|
|
```
|
|
The {% include %} is INSIDE the {% for p in printers %} loop — it inherits the `p` variable directly.
|
|
|
|
**Step 3 — Update _render_printer_list in api/printers.py if needed:**
|
|
Check if `clients` is in the context passed to printer_list.html. The current _render_printer_list only passes `grouped`. Add clients to the context:
|
|
```python
|
|
def _render_printer_list(request: Request) -> HTMLResponse:
|
|
from imptune.db.models import Client, Driver
|
|
import json
|
|
# existing grouped query...
|
|
clients = list(Client.select().order_by(Client.name))
|
|
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
|
driver_data = [
|
|
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
|
|
for d in all_drivers
|
|
]
|
|
return templates.TemplateResponse(
|
|
request=request,
|
|
name="partials/printer_list.html",
|
|
context={"grouped": grouped, "clients": clients, "driver_data": driver_data},
|
|
)
|
|
```
|
|
|
|
**Step 4 — Create tests/e2e/test_printer_edit.py:**
|
|
|
|
```python
|
|
"""UIE-01: E2E test for printer edit modal — open, pre-fill, submit, list update."""
|
|
from __future__ import annotations
|
|
import pytest
|
|
|
|
|
|
def test_printer_edit_modal_open_and_prefill(page, live_server: str) -> None:
|
|
"""Edit button opens modal with printer's current name pre-filled."""
|
|
import httpx
|
|
# Create a printer via API
|
|
with httpx.Client(base_url=live_server, follow_redirects=True) as api:
|
|
api.post("/printers", data={
|
|
"name": "EditTest Printer",
|
|
"ip_address": "10.0.5.1",
|
|
"port_name": "IP_10_0_5_1",
|
|
})
|
|
|
|
page.goto(f"{live_server}/printers", wait_until="domcontentloaded")
|
|
page.wait_for_selector("button:has-text('Edit')")
|
|
page.click("button:has-text('Edit')")
|
|
|
|
# Dialog should be open
|
|
page.wait_for_selector("dialog[open]")
|
|
# Name input should be pre-filled
|
|
name_val = page.input_value("dialog[open] input[name='name']")
|
|
assert name_val == "EditTest Printer"
|
|
|
|
|
|
def test_printer_edit_submit_updates_list(page, live_server: str) -> None:
|
|
"""Submitting the edit form updates the printer name in the list (no page reload)."""
|
|
import httpx
|
|
with httpx.Client(base_url=live_server, follow_redirects=True) as api:
|
|
api.post("/printers", data={
|
|
"name": "OriginalName",
|
|
"ip_address": "10.0.5.2",
|
|
"port_name": "IP_10_0_5_2",
|
|
})
|
|
|
|
page.goto(f"{live_server}/printers", wait_until="domcontentloaded")
|
|
page.wait_for_selector("button:has-text('Edit')")
|
|
page.click("button:has-text('Edit')")
|
|
page.wait_for_selector("dialog[open]")
|
|
|
|
# Clear and update the name field
|
|
page.fill("dialog[open] input[name='name']", "UpdatedName")
|
|
page.click("dialog[open] button[type='submit']")
|
|
|
|
# Modal should close and list should update
|
|
page.wait_for_selector("#printer-list")
|
|
assert "UpdatedName" in page.text_content("#printer-list")
|
|
assert "OriginalName" not in page.text_content("#printer-list")
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_printer_crud.py -x -q -k "patch_printer" && pytest tests/ -x -q --ignore=tests/e2e 2>&1 | tail -10</automated>
|
|
E2E separately: pytest tests/e2e/test_printer_edit.py -x -q (requires live server + playwright)
|
|
</verify>
|
|
<done>
|
|
- Edit button appears in every printer row
|
|
- Clicking Edit opens a pre-filled dialog
|
|
- Submitting saves changes and refreshes the list
|
|
- test_patch_printer and test_patch_printer_not_found GREEN
|
|
- test_printer_edit_modal_open_and_prefill and test_printer_edit_submit_updates_list pass (E2E)
|
|
- Full integration suite (non-E2E) GREEN
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
Run full non-E2E suite:
|
|
```
|
|
cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/ -x -q --ignore=tests/e2e
|
|
```
|
|
Expected: all GREEN.
|
|
|
|
Run E2E:
|
|
```
|
|
cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/e2e/ -q
|
|
```
|
|
Expected: test_printer_edit.py passes (2 tests GREEN).
|
|
|
|
Spot-check: PATCH /printers/{id} with valid data returns 200, response HTML contains updated name.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Every printer row has an Edit button
|
|
- Clicking Edit opens a native dialog pre-filled with that printer's current data
|
|
- Submitting the edit form sends HTMX PATCH, closes the modal, and updates the list
|
|
- PATCH /printers/{id} validated via test_patch_printer (GREEN)
|
|
- PATCH /printers/9999 returns 404 (confirmed by test_patch_printer_not_found)
|
|
- E2E tests pass: modal opens, name is pre-filled, submit updates list
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/11-ui-enhancements/11-02-SUMMARY.md`
|
|
</output>
|