Commit initial
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user