Files
ImpTune/.planning/phases/11-ui-enhancements/11-02-PLAN.md
T
kawaandClaude Sonnet 4.6 0badba20d1 docs(11): create phase plan
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>
2026-04-15 10:47:19 +02:00

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
11-01
imptune/api/printers.py
imptune/templates/partials/printer_list.html
imptune/templates/partials/printer_edit_modal.html
tests/e2e/test_printer_edit.py
true
UIE-01
truths artifacts key_links
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
path provides min_lines
imptune/templates/partials/printer_edit_modal.html Edit modal template with pre-filled fields and PATCH form 40
path provides contains
imptune/api/printers.py PATCH /printers/{id} route handler @router.patch
path provides min_lines
tests/e2e/test_printer_edit.py E2E test: modal open, pre-fill verification, submit, list update 20
from to via pattern
imptune/templates/partials/printer_list.html printer_edit_modal.html {% include %} inside {% for p in printers %} loop include.*printer_edit_modal
from to via pattern
printer_edit_modal.html PATCH /printers/{id} hx-patch attribute on the edit form hx-patch
from to via pattern
imptune/api/printers.py update_printer _render_printer_list return _render_printer_list(request) on success _render_printer_list
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.

<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.md

From 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.
cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_printer_crud.py -x -q -k "patch_printer" 2>&1 | tail -10 test_patch_printer and test_patch_printer_not_found both GREEN; existing DELETE tests still pass. Task 2: Edit button, edit modal partial, and E2E test imptune/templates/partials/printer_list.html, imptune/templates/partials/printer_edit_modal.html, tests/e2e/test_printer_edit.py **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")
```
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 E2E separately: pytest tests/e2e/test_printer_edit.py -x -q (requires live server + playwright) - 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 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.

<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>
After completion, create `.planning/phases/11-ui-enhancements/11-02-SUMMARY.md`