Plan 4 plans across 3 waves covering UIE-01..05: form separation (Plan 01), printer edit modal (Plan 02), theme+i18n toggles (Plan 03), and client detail page (Plan 04). Wave 0 test scaffolds included in Plan 01. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
18 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11-ui-enhancements | 02 | execute | 2 |
|
|
true |
|
|
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.
<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>
@.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.mdFrom imptune/api/printers.py (existing helpers — reuse unchanged):
_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:
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):
<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):
<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):
<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:
<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).
Task 1: PATCH /printers/{id} route handler imptune/api/printers.py - 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) 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.
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")
```
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.
<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>