From 0badba20d161999ca68c95714c70becb5aeb6bb9 Mon Sep 17 00:00:00 2001 From: Kawa Date: Wed, 15 Apr 2026 10:47:19 +0200 Subject: [PATCH] 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 --- .planning/ROADMAP.md | 32 +- .../phases/11-ui-enhancements/11-01-PLAN.md | 256 +++++++++ .../phases/11-ui-enhancements/11-02-PLAN.md | 481 +++++++++++++++++ .../phases/11-ui-enhancements/11-03-PLAN.md | 503 ++++++++++++++++++ .../phases/11-ui-enhancements/11-04-PLAN.md | 301 +++++++++++ 5 files changed, 1558 insertions(+), 15 deletions(-) create mode 100644 .planning/phases/11-ui-enhancements/11-01-PLAN.md create mode 100644 .planning/phases/11-ui-enhancements/11-02-PLAN.md create mode 100644 .planning/phases/11-ui-enhancements/11-03-PLAN.md create mode 100644 .planning/phases/11-ui-enhancements/11-04-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 3dc7e67..7d55bcf 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -3,7 +3,7 @@ ## Milestones - ✅ **v1.0 MVP** — Phases 1–7, 13 plans, 27/27 requirements (shipped 2026-04-13) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md) -- 🚧 **v1.1 Hardening & Validation** — Phases 8–11, 15 requirements (in progress, started 2026-04-13) +- 🚧 **v1.1 Hardening & Validation** — Phases 8–11, 15 requirements + UI enhancements (in progress, started 2026-04-13) ## Phases @@ -28,7 +28,7 @@ Full details: [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md) - [x] **Phase 9: UX Tech Debt Closure** — Fix the three carried-over UX gaps so the deployed build is the polished one technicians actually use (completed 2026-04-13) - [x] **Phase 10: Real-World Runtime Validation** — Validate generated artifacts end-to-end against a live Intune tenant and a real managed endpoint (completed 2026-04-13) -- [x] **Phase 11: Real-World Rollout & Feedback** — Deploy the container to a real MSP environment, push a real package, and capture structured technician feedback (completed 2026-04-13) +- [ ] **Phase 11: UI Enhancements** — Add printer edit, separate form from list, clickable client names, dark/light mode toggle, and French/English language switch ## Phase Details @@ -78,19 +78,21 @@ Full details: [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md) - [ ] 10-02-live-intune-runtime-validation-PLAN.md — Drive RTVAL-01..04 manual checkpoints against a live Intune tenant + real Windows endpoint, capturing screenshots and device logs as evidence - [ ] 10-03-report-signoff-PLAN.md — Finalize RUNTIME-VALIDATION.md, human sign-off, tick RTVAL-01..05 and mark Phase 10 complete -### Phase 11: Real-World Rollout & Feedback -**Goal**: ImpTune is running in a real MSP environment, has produced a package that actually reached endpoints, and technician feedback has been captured and triaged. -**Depends on**: Phase 9 (UX tech debt), Phase 10 (runtime validation passed) -**Requirements**: RWR-01, RWR-02, RWR-03, RWR-04 +### Phase 11: UI Enhancements +**Goal**: Improve the daily usability of ImpTune with printer editing, better form/list layout, client-scoped navigation, dark/light theme, and bilingual (FR/EN) support. +**Depends on**: Phase 9 (polished base build), Phase 10 (runtime validation passed) +**Requirements**: UIE-01, UIE-02, UIE-03, UIE-04, UIE-05 **Success Criteria** (what must be TRUE): - 1. ImpTune is running in its single Docker container inside at least one real MSP environment serving real printers, with the host/URL recorded in the feedback document - 2. At least one printer deployment package generated by the deployed instance has been pushed to real endpoints via Intune or NinjaRMM end-to-end - 3. A reviewer can open `.planning/feedback/v1.1-rollout.md` and read structured feedback from the deploying technician covering what worked, what blocked, and what's missing - 4. Every feedback item in that document is tagged fix-in-v1.1, defer-to-v1.2, or won't-do with written rationale -**Plans**: 3 plans - - [x] 11-01-deploy-container-to-msp-PLAN.md — Build + deploy ImpTune container into real MSP environment and record host/URL/commit (RWR-01) — closed by operator attestation (local Docker host) 2026-04-13 - - [x] 11-02-push-real-package-PLAN.md — Generate a real package via the deployed instance and push to a real endpoint via Intune/NinjaRMM (RWR-02) — delivered end-to-end via BOTH Intune and NinjaRMM, operator attestation 2026-04-13 - - [x] 11-03-capture-and-triage-feedback-PLAN.md — Capture structured technician feedback in .planning/feedback/v1.1-rollout.md and triage every item (RWR-03, RWR-04) — feedback doc written, zero items to triage (all worked) 2026-04-13 + 1. Every printer in the list has an Edit button that opens a pre-filled form and saves changes in-place without losing other printer data + 2. The new-printer form is visually separated from the printer list (distinct section, card, or page) so adding a printer doesn't feel buried in the list + 3. Every client name in the interface is a clickable link that navigates to a filtered page showing only that client's printers + 4. A toggle lets the user switch between Dark mode, Light mode, and Follow system — the chosen preference persists across page reloads + 5. A toggle lets the user switch the UI language between French and English — all labels, buttons, and messages update immediately and the choice persists +**Plans**: 4 plans + - [ ] 11-01-PLAN.md — Wave 0 test scaffolds + UIE-02: dedicated /printers/new page + POST redirect (UIE-02) + - [ ] 11-02-PLAN.md — Printer edit modal: PATCH /printers/{id} + native dialog + Edit button per row (UIE-01) + - [ ] 11-03-PLAN.md — Theme toggle + FR/EN language toggle in base.html via Alpine.js stores (UIE-04, UIE-05) + - [ ] 11-04-PLAN.md — Client detail page /clients/{id} + clickable client names everywhere (UIE-03) ## Progress @@ -106,4 +108,4 @@ Full details: [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md) | 8. Nyquist Validation Track | v1.1 | 8/8 | Complete | 2026-04-13 | | 9. UX Tech Debt Closure | 3/3 | Complete | 2026-04-13 | 2026-04-13 | | 10. Real-World Runtime Validation | v1.1 | 3/3 | Complete | 2026-04-13 | -| 11. Real-World Rollout & Feedback | v1.1 | 3/3 | Complete | 2026-04-13 | +| 11. UI Enhancements | v1.1 | 0/4 | Planned | | diff --git a/.planning/phases/11-ui-enhancements/11-01-PLAN.md b/.planning/phases/11-ui-enhancements/11-01-PLAN.md new file mode 100644 index 0000000..4571f09 --- /dev/null +++ b/.planning/phases/11-ui-enhancements/11-01-PLAN.md @@ -0,0 +1,256 @@ +--- +phase: 11-ui-enhancements +plan: "01" +type: execute +wave: 1 +depends_on: [] +files_modified: + - tests/test_printer_crud.py + - imptune/api/pages.py + - imptune/api/printers.py + - imptune/templates/printers.html + - imptune/templates/printers_new.html +autonomous: true +requirements: + - UIE-02 +must_haves: + truths: + - "GET /printers/new returns 200 with the Add Printer form" + - "POST /printers returns 303 redirect to /printers (no HX-Request header)" + - "GET /printers no longer contains the add-printer form markup" + - "An 'Add Printer' link on /printers navigates to /printers/new" + - "Existing CRUD tests still pass after the redirect behavior change" + artifacts: + - path: "imptune/templates/printers_new.html" + provides: "Dedicated Add Printer page (GET /printers/new)" + min_lines: 15 + - path: "imptune/templates/printers.html" + provides: "Printer Library page — list only, no inline form" + contains: "/printers/new" + - path: "imptune/api/pages.py" + provides: "GET /printers/new route" + exports: ["printers_new_page"] + - path: "imptune/api/printers.py" + provides: "POST /printers always returns 303 redirect" + contains: "RedirectResponse" + key_links: + - from: "imptune/templates/printers_new.html" + to: "POST /printers" + via: "plain
(no hx-post) so browser follows 303" + pattern: "action=\"/printers\"" + - from: "imptune/api/printers.py" + to: "/printers" + via: "RedirectResponse(url='/printers', status_code=303)" + pattern: "RedirectResponse" +--- + + +Separate the Add Printer form from the Printer Library and write Wave 0 test scaffolds for the whole phase. + +Purpose: UIE-02 — Users need to add a printer on a dedicated page, not buried inside the printer list. The printer library at /printers becomes list-only with a visible "Add Printer" link. + +Output: GET /printers/new page, updated POST /printers (303 redirect), stripped printers.html, and all integration test scaffolds for UIE-01/02/03. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-ui-enhancements/11-CONTEXT.md +@.planning/phases/11-ui-enhancements/11-RESEARCH.md +@.planning/phases/11-ui-enhancements/11-VALIDATION.md + + + + +From imptune/api/pages.py: +```python +# Existing printers_page route (to be modified) +@router.get("/printers", response_class=HTMLResponse) +def printers_page(request: Request): + # ... loads grouped, clients, driver_data ... + return templates.TemplateResponse( + request=request, + name="printers.html", + context={"grouped": grouped, "clients": clients, "driver_data": driver_data}, + ) +``` + +From imptune/api/printers.py: +```python +# Existing POST handler (to be changed to always redirect) +@router.post("", response_class=HTMLResponse) +def create_printer(request: Request, ...) -> HTMLResponse: + # ... validate + create ... + return _render_printer_list(request) # CHANGE: return RedirectResponse instead +``` + +From imptune/templates/printers.html (current): +```html +{% block content %} +

Printers

+
+

Add Printer

+ {% include "partials/printer_form.html" %} +
+
+

Printer Library

+ {% include "partials/printer_list.html" %} +
+{% endblock %} +``` + +From tests/conftest.py: +```python +# TestClient fixture — no follow_redirects by default (httpx default is True in TestClient) +# Use client.post(...) and check response.status_code == 303 for redirect tests +# Use follow_redirects=False in specific tests via: client.post(..., follow_redirects=False) +``` +
+
+ + + + + Task 1: Wave 0 — Add integration test scaffolds for UIE-01, UIE-02, UIE-03 + tests/test_printer_crud.py + + - test_printers_new_returns_200: GET /printers/new returns 200 with add form markup (contains 'Printer Name' or name="name") + - test_create_printer_redirects: POST /printers (no HX-Request) returns 303 to /printers (follow_redirects=False) + - test_printers_library_no_form: GET /printers does NOT contain the add-printer form (does not contain hx-post="/printers" or the form's submit button text "Save Printer") + - test_patch_printer: PATCH /printers/{id} with updated name returns 200, updated name appears in response HTML, DB record updated + - test_patch_printer_not_found: PATCH /printers/9999 returns 404 + - test_client_detail_returns_200: GET /clients/{id} (after creating client + printer assigned to it) returns 200 with client name and printer name in HTML + - test_client_detail_not_found: GET /clients/9999 returns 404 + - test_client_links_in_printer_list: GET /printers with a printer assigned to a client contains href="/clients/{client_id}" in the response HTML + All RED: these tests must FAIL before Plan 01 Task 2 implements the changes (except existing tests which must stay GREEN) + + + Add the following test functions to the END of tests/test_printer_crud.py. Do not modify existing tests. + + Import notes: no new imports needed beyond what is already imported (pytest, TestClient, Client, Printer from imptune.db.models are all available via conftest). + + For test_create_printer_redirects: call client.post("/printers", data={...}, follow_redirects=False) and assert resp.status_code == 303 and resp.headers["location"] == "/printers". + + For test_patch_printer: create a Printer directly via Printer.create(), then call client.patch(f"/printers/{printer.id}", data={...}) with an updated name, assert 200, assert updated name in resp.text, re-query DB to confirm Printer.get_by_id(printer.id).name == updated name. + + For test_client_links_in_printer_list: create a client via POST /clients, create a printer assigned to that client via Printer.create(), GET /printers, assert f'href="/clients/{client.id}"' in resp.text. + + Existing tests that POST to /printers (e.g. test_create_printer_persisted) will break after Task 2 changes the POST handler. Add a FIXME comment above each existing POST test noting they will be updated in Task 2, but do NOT change them yet — let them go RED as part of TDD RED state. + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_printer_crud.py -x -q -k "printers_new or redirects or library_no_form or patch_printer or client_detail or client_not_found or client_links" 2>&1 | tail -10 + Expected: all new tests FAIL (RED state — routes/templates do not exist yet). + + New test functions exist in test_printer_crud.py; running them against current code produces FAIL/ERROR (not ImportError); existing passing tests still pass when run without the new tests. + + + + Task 2: UIE-02 — Separate form from library (GET /printers/new + POST redirect) + + imptune/api/pages.py, + imptune/api/printers.py, + imptune/templates/printers.html, + imptune/templates/printers_new.html + + + - GET /printers returns 200; response does NOT contain the printer form (no "Save Printer" button, no hx-post="/printers") + - GET /printers contains an "Add Printer" link/button pointing to /printers/new + - GET /printers/new returns 200 and contains the printer form (contains name="name", name="ip_address") + - POST /printers (plain form, no HX-Request header) returns 303 redirect to /printers + - After redirect, GET /printers shows the newly created printer in the list + + + **Step 1 — Create imptune/templates/printers_new.html:** + New full page template extending base.html. Block content contains: + - <h1> heading (e.g. "Add Printer" — use x-text="$store.i18n.t('add_printer')" when UIE-05 lands; for now, hardcode "Add Printer") + - <a href="/printers"> back link + - {% include "partials/printer_form.html" %} — reuse the existing partial unchanged; it already has all fields and the driver upload sub-form + Context vars needed: clients (list of Client), driver_data (list of {driver, names}) + + **Step 2 — Update imptune/templates/printers.html:** + Remove the entire "Add Printer" <section> block (the {% include "partials/printer_form.html" %} section). + Replace it with a prominent "Add Printer" link styled as a button: <a href="/printers/new" role="button">Add Printer</a> + Keep the "Printer Library" section with {% include "partials/printer_list.html" %} unchanged. + + **Step 3 — Update imptune/api/pages.py:** + a) Modify printers_page (GET /printers): remove the driver_data context since the form is no longer there. Keep grouped and clients for the list rendering and modal (Plan 02 will need them). Actually keep driver_data — it will be needed by the edit modal in Plan 02. Leave context unchanged. + b) Add new route GET /printers/new: + ```python + @router.get("/printers/new", response_class=HTMLResponse) + def printers_new_page(request: Request): + from imptune.db.models import Client, Driver + import json + 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="printers_new.html", + context={"clients": clients, "driver_data": driver_data}, + ) + ``` + IMPORTANT: Place this route BEFORE the GET /printers/{printer_id} route in pages.py to avoid FastAPI routing the literal string "new" as a printer_id int (FastAPI path parameter typing already handles this since printer_id is typed int, but explicit ordering avoids ambiguity). + + **Step 4 — Update imptune/api/printers.py:** + Change the POST /printers handler to always return a RedirectResponse: + ```python + from fastapi.responses import HTMLResponse, RedirectResponse + # ... after Printer.create() succeeds ... + return RedirectResponse(url="/printers", status_code=303) + ``` + Remove the `return _render_printer_list(request)` line at the end of create_printer. The _render_printer_list helper stays (used by DELETE and future PATCH in Plan 02). + + **Step 5 — Fix existing tests broken by redirect:** + Update existing tests in test_printer_crud.py that POST to /printers and previously asserted status_code == 200: + - For tests that just test DB persistence (test_create_printer_persisted, test_create_printer_duplex, etc.): change assertion from `assert resp.status_code == 200` to `assert resp.status_code == 303`. The DB create still happens before the redirect. These tests do not need to follow the redirect. + - For test_create_printer_persisted: after the POST, do a separate `client.get("/printers")` to verify the name appears (the existing code already does this — just update the status_code assertion for the POST itself). + - Do NOT change the 400 error tests (test_create_printer_missing_name, test_create_printer_invalid_ip) — error responses are still returned directly (no redirect on validation failure). + + Note: printer_form.html currently uses hx-post="/printers". Since printers_new.html will use {% include "partials/printer_form.html" %}, the form will submit via HTMX by default. Change the form action in printer_form.html to use a plain form without HTMX on /printers/new by one of two approaches: + - Option A (preferred): In printers_new.html, do NOT include printer_form.html via {% include %}. Instead, copy the form markup inline but replace hx-post="/printers" with action="/printers" method="post" (plain HTML form). This ensures the browser follows the 303 redirect naturally. + - The driver upload sub-form can stay as-is with hx-post (it has its own target and handler). + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_printer_crud.py -x -q -k "printers_new or redirects or library_no_form" 2>&1 | tail -15 + Also run: pytest tests/test_printer_crud.py -x -q 2>&1 | tail -10 (all tests GREEN) + + GET /printers/new returns 200 with form; POST /printers returns 303; GET /printers contains "Add Printer" link but no form; all test_printer_crud.py tests pass. + + + + + +Run full test suite (excluding E2E) after both tasks complete: + +``` +cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/ -x -q --ignore=tests/e2e +``` + +Expected: all tests GREEN. + +Spot-check: +- `pytest tests/test_printer_crud.py -x -q -k "printers_new or redirects or library_no_form"` — GREEN (UIE-02 tests) +- `pytest tests/test_printer_crud.py -x -q -k "patch_printer or client_detail or client_not_found or client_links"` — RED (UIE-01/03 tests scaffold exists but routes not yet built — expected RED at end of Plan 01) + + + +- GET /printers/new returns 200 with the Add Printer form (all printer fields present) +- POST /printers returns 303 redirect to /printers (confirmed by test) +- GET /printers does NOT contain the Add Printer form markup +- GET /printers contains a link to /printers/new +- All existing tests in test_printer_crud.py pass (adjusted for 303 on POST) +- Wave 0 scaffolds for UIE-01 and UIE-03 exist in test_printer_crud.py (RED, not ERROR) + + + +After completion, create `.planning/phases/11-ui-enhancements/11-01-SUMMARY.md` + diff --git a/.planning/phases/11-ui-enhancements/11-02-PLAN.md b/.planning/phases/11-ui-enhancements/11-02-PLAN.md new file mode 100644 index 0000000..6aa2117 --- /dev/null +++ b/.planning/phases/11-ui-enhancements/11-02-PLAN.md @@ -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 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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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): +```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 + + + +``` + +Pico CSS dialog pattern (from RESEARCH.md): +```html + +
+
+ +

Edit Printer

+
+ +
+
+``` + +HTMX PATCH + close after success (from RESEARCH.md): +```html + +``` + +Alpine.js portEdited in edit mode — must be TRUE (not false) so editing IP does not overwrite a manually set port: +```html +
+``` + +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 Printer

+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ ``` + + 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 ``, include the edit modal partial: + ```html + + {% include "partials/printer_edit_modal.html" %} + + + ``` + 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. + + + +- 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 + + + +After completion, create `.planning/phases/11-ui-enhancements/11-02-SUMMARY.md` + diff --git a/.planning/phases/11-ui-enhancements/11-03-PLAN.md b/.planning/phases/11-ui-enhancements/11-03-PLAN.md new file mode 100644 index 0000000..f2474a5 --- /dev/null +++ b/.planning/phases/11-ui-enhancements/11-03-PLAN.md @@ -0,0 +1,503 @@ +--- +phase: 11-ui-enhancements +plan: "03" +type: execute +wave: 2 +depends_on: + - "11-01" +files_modified: + - imptune/templates/base.html + - tests/test_static.py + - tests/e2e/test_theme_toggle.py + - tests/e2e/test_i18n_toggle.py +autonomous: true +requirements: + - UIE-04 + - UIE-05 +must_haves: + truths: + - "A theme toggle button is visible on every page in the top-right area" + - "Clicking the theme button cycles data-theme on through light -> dark -> auto" + - "The chosen theme persists across page reloads (stored in localStorage)" + - "A FR/EN toggle is visible in the top-right area alongside the theme button" + - "Clicking the language toggle switches all static UI labels (nav items, buttons, headings) between French and English" + - "The chosen language persists across page reloads (stored in localStorage)" + artifacts: + - path: "imptune/templates/base.html" + provides: "Top-right controls with theme + language toggles, Alpine.js stores" + contains: "Alpine.store" + - path: "tests/e2e/test_theme_toggle.py" + provides: "E2E: theme button cycles data-theme, localStorage persists" + min_lines: 20 + - path: "tests/e2e/test_i18n_toggle.py" + provides: "E2E: lang toggle switches nav labels, localStorage persists" + min_lines: 20 + key_links: + - from: "base.html alpine:init script" + to: "Alpine.store('theme') + Alpine.store('i18n')" + via: "document.addEventListener('alpine:init', ...) before Alpine defer load" + pattern: "alpine:init" + - from: "Alpine.store('theme').cycle()" + to: "document.documentElement.setAttribute('data-theme', ...)" + via: "Alpine store method called on button click" + pattern: "data-theme" + - from: "nav links in base.html" + to: "Alpine.store('i18n').t('key')" + via: "x-text binding on each nav link and button" + pattern: "\\$store\\.i18n\\.t" +--- + + +Add theme toggle (Light/Dark/System) and FR/EN language toggle to the global layout, entirely in base.html using Alpine.js stores. + +Purpose: UIE-04 + UIE-05 — Users need persistent theme preference and bilingual support. Both features live in base.html with Alpine.js $store — zero new backend routes, zero new dependencies. + +Output: Updated base.html with top-right controls, Alpine.js theme + i18n stores, E2E tests for both toggles. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/11-ui-enhancements/11-CONTEXT.md +@.planning/phases/11-ui-enhancements/11-RESEARCH.md + + + + +Current base.html structure (full file): +```html + + + + + + ImpTune + + + + + + +
+ +
+ {% block content %}{% endblock %} +
+
+ + +``` + +Alpine.js store + alpine:init pattern (from RESEARCH.md): +```javascript +document.addEventListener('alpine:init', () => { + Alpine.store('theme', { ... }); + Alpine.store('i18n', { ... }); +}); +``` +This script MUST run BEFORE alpine.min.js `defer` executes. Place the script tag before the ` + ``` + + **Step 2 — Add top-right controls area to the layout in base.html:** + + Inside the `
`, add a top-right controls bar above the main content area. Modify the layout to include a controls area: + + ```html +
+ +
+
+
+ + + + +
+
+
+ {% block content %}{% endblock %} +
+
+
+ ``` + + Note on x-data: Since the buttons use $store (global), they need Alpine to be active. Each button element gets a minimal `x-data` attribute (empty string is fine) to be scoped into Alpine. Alternatively wrap the .topbar-controls div with x-data. + + **Step 3 — Add minimal CSS for topbar to imptune/static/app.css (if needed):** + + The topbar does not need app.css changes for basic functionality — Pico CSS handles button styles. BUT if the layout currently uses CSS grid/flex that doesn't accommodate the new .main-wrapper and .topbar, add minimal styles. Check existing app.css first. If .layout is a CSS grid with sidebar + main-content columns, wrap main-content in main-wrapper and update the grid to target .main-wrapper. Keep app.css changes minimal. + + NOTE: Do not modify app.css if it would break existing tests. The test_no_cdn_urls_in_templates test only checks HTML, not CSS. + + **Step 4 — Add integration test to tests/test_static.py:** + + Add function: + ```python + def test_theme_toggle_present(client): + """GET / contains a theme toggle button (data-theme cycling control).""" + response = client.get("/") + assert response.status_code == 200 + # The button's @click should reference $store.theme.cycle + assert "theme" in response.text + assert "cycle" in response.text or "store.theme" in response.text + ``` + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_static.py -x -q -k "theme_toggle_present" 2>&1 | tail -10 + Also: pytest tests/ -x -q --ignore=tests/e2e (full non-E2E suite GREEN) + + + - base.html contains Alpine.js store definitions (theme + i18n) + - Theme toggle button and FR/EN button visible on layout + - test_theme_toggle_present passes + - test_no_cdn_urls_in_templates still passes (no external URLs added) + - All non-E2E tests GREEN + + + + + Task 2: E2E tests for theme toggle and language toggle + tests/e2e/test_theme_toggle.py, tests/e2e/test_i18n_toggle.py + + **Step 1 — Create tests/e2e/test_theme_toggle.py:** + + ```python + """UIE-04: E2E tests for theme toggle — data-theme cycling and localStorage persistence.""" + from __future__ import annotations + import pytest + + + def test_theme_cycles_on_click(page, live_server: str) -> None: + """Clicking theme button cycles data-theme attribute: auto -> light -> dark -> auto.""" + page.goto(f"{live_server}/", wait_until="domcontentloaded") + + # Initial state: auto (default from base.html) + initial_theme = page.evaluate("document.documentElement.getAttribute('data-theme')") + assert initial_theme == "auto" + + # Click once -> light + page.click("button[aria-label='auto']") + page.wait_for_function( + "document.documentElement.getAttribute('data-theme') === 'light'", + timeout=2000, + ) + assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "light" + + # Click again -> dark + page.click("button[aria-label='light']") + page.wait_for_function( + "document.documentElement.getAttribute('data-theme') === 'dark'", + timeout=2000, + ) + assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "dark" + + + def test_theme_persists_across_reload(page, live_server: str) -> None: + """After clicking theme toggle, the chosen theme is restored on reload.""" + page.goto(f"{live_server}/", wait_until="domcontentloaded") + + # Switch to light mode + page.click("button[aria-label='auto']") + page.wait_for_function( + "document.documentElement.getAttribute('data-theme') === 'light'", + timeout=2000, + ) + + # Reload the page + page.reload(wait_until="domcontentloaded") + + # Theme should still be light (from localStorage) + theme_after_reload = page.evaluate("document.documentElement.getAttribute('data-theme')") + assert theme_after_reload == "light" + + # Cleanup: reset to auto + page.evaluate("localStorage.setItem('imptune_theme', 'auto')") + ``` + + **Step 2 — Create tests/e2e/test_i18n_toggle.py:** + + ```python + """UIE-05: E2E tests for language toggle — FR/EN switching and localStorage persistence.""" + from __future__ import annotations + import pytest + + + def test_language_toggle_switches_nav_label(page, live_server: str) -> None: + """Clicking FR/EN button switches nav label from French to English.""" + page.goto(f"{live_server}/", wait_until="domcontentloaded") + + # Default lang is 'fr' — nav should show French labels + # Wait for Alpine to hydrate + page.wait_for_function( + "document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''", + timeout=3000, + ) + + # In French, printers nav label = 'Imprimantes' + printers_label_fr = page.text_content("nav a[href='/printers']").strip() + assert printers_label_fr == "Imprimantes", f"Expected 'Imprimantes', got '{printers_label_fr}'" + + # Click the language toggle button + page.click("button[title='Toggle language']") + + # Wait for label to update + page.wait_for_function( + "document.querySelector('nav a[href=\"/printers\"]').textContent.trim() === 'Printers'", + timeout=2000, + ) + + printers_label_en = page.text_content("nav a[href='/printers']").strip() + assert printers_label_en == "Printers" + + + def test_language_persists_across_reload(page, live_server: str) -> None: + """After switching to EN, language is preserved on page reload.""" + page.goto(f"{live_server}/", wait_until="domcontentloaded") + + # Switch to English + page.click("button[title='Toggle language']") + page.wait_for_function( + "document.querySelector('nav a[href=\"/printers\"]').textContent.trim() === 'Printers'", + timeout=2000, + ) + + # Reload + page.reload(wait_until="domcontentloaded") + page.wait_for_function( + "document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''", + timeout=3000, + ) + + label_after_reload = page.text_content("nav a[href='/printers']").strip() + assert label_after_reload == "Printers" + + # Cleanup: reset to fr + page.evaluate("localStorage.setItem('imptune_lang', 'fr')") + ``` + + Note on E2E test selectors: these tests use `button[aria-label='auto']` for theme and `button[title='Toggle language']` for i18n. These selectors must match what Task 1 renders in base.html. Verify the button attributes in the template match the test selectors. If different approaches were chosen in Task 1 (e.g., different aria-label strategy), update the selectors to match. + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/e2e/test_theme_toggle.py tests/e2e/test_i18n_toggle.py -x -q 2>&1 | tail -15 + + + - test_theme_cycles_on_click: data-theme cycles auto -> light -> dark on button clicks + - test_theme_persists_across_reload: theme persists after page reload + - test_language_toggle_switches_nav_label: nav label switches from Imprimantes to Printers on toggle + - test_language_persists_across_reload: language choice persists after reload + All 4 E2E tests GREEN. + + + + + + +Run full non-E2E suite: +``` +cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/ -x -q --ignore=tests/e2e +``` +Expected: all GREEN (including test_no_cdn_urls_in_templates — no external URLs in base.html). + +Run E2E for this plan: +``` +cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/e2e/test_theme_toggle.py tests/e2e/test_i18n_toggle.py -q +``` +Expected: 4 tests GREEN. + +Manual spot-check (checkpoint:human-verify handled by /gsd:verify-work): +- Open any page — theme button and FR/EN button visible in top-right area +- Click theme button — dark mode activates (background goes dark) +- Reload — dark mode persists +- Click FR/EN — nav labels switch language +- Reload — language persists + + + +- Theme toggle button visible on all pages; cycles data-theme: auto -> light -> dark -> auto +- Chosen theme persists across page reloads (localStorage key: imptune_theme) +- FR/EN toggle button visible alongside theme button; all nav labels, heading labels switch language +- Chosen language persists across page reloads (localStorage key: imptune_lang) +- test_theme_toggle_present (integration) GREEN +- All 4 E2E tests GREEN (theme cycles, theme persists, lang switches, lang persists) +- test_no_cdn_urls_in_templates still GREEN (no CDN URLs added) + + + +After completion, create `.planning/phases/11-ui-enhancements/11-03-SUMMARY.md` + diff --git a/.planning/phases/11-ui-enhancements/11-04-PLAN.md b/.planning/phases/11-ui-enhancements/11-04-PLAN.md new file mode 100644 index 0000000..3fe83e9 --- /dev/null +++ b/.planning/phases/11-ui-enhancements/11-04-PLAN.md @@ -0,0 +1,301 @@ +--- +phase: 11-ui-enhancements +plan: "04" +type: execute +wave: 3 +depends_on: + - "11-02" + - "11-03" +files_modified: + - imptune/api/pages.py + - imptune/templates/client_detail.html + - imptune/templates/partials/client_list.html + - imptune/templates/partials/printer_list.html +autonomous: true +requirements: + - UIE-03 +must_haves: + truths: + - "Every client name in the printer list group headers is a clickable link to /clients/{id}" + - "Every client name in the client list table is a clickable link to /clients/{id}" + - "GET /clients/{id} returns 200 with the client name as page title and only that client's printers listed" + - "GET /clients/9999 returns 404" + - "Edit and Delete actions on /clients/{id} work the same as on /printers" + artifacts: + - path: "imptune/templates/client_detail.html" + provides: "Per-client page showing client name + filtered printer list" + min_lines: 15 + - path: "imptune/api/pages.py" + provides: "GET /clients/{client_id} route" + exports: ["client_detail"] + - path: "imptune/templates/partials/client_list.html" + provides: "Client names wrapped in " + contains: "/clients/" + - path: "imptune/templates/partials/printer_list.html" + provides: "Group headers with client name as " + contains: "/clients/" + key_links: + - from: "imptune/templates/partials/client_list.html" + to: "/clients/{c.id}" + via: "{{ c.name }}" + pattern: "href.*clients.*c\\.id" + - from: "imptune/templates/partials/printer_list.html" + to: "/clients/{client_id}" + via: "{{ client_name }} in group header

" + pattern: "href.*clients" + - from: "imptune/api/pages.py client_detail" + to: "printer_list partial" + via: "grouped = {client.name: list(query)} passed to client_detail.html which includes printer_list" + pattern: "grouped" +--- + + +Make client names clickable everywhere they appear, and add the per-client printer page at GET /clients/{id}. + +Purpose: UIE-03 — Users managing multiple clients need a quick way to see only one client's printers. A clickable client name in the list/headers navigates to a filtered view without any extra search UI. + +Output: GET /clients/{client_id} route, client_detail.html template, updated client_list.html and printer_list.html with client name links. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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-02-SUMMARY.md +@.planning/phases/11-ui-enhancements/11-03-SUMMARY.md + + + + +From imptune/api/pages.py — existing clients_page route (for reference): +```python +@router.get("/clients", response_class=HTMLResponse) +def clients_page(request: Request): + from imptune.db.models import Client + clients = list(Client.select().order_by(Client.name)) + return templates.TemplateResponse( + request=request, + name="clients.html", + context={"clients": clients}, + ) +``` +New route to add: +```python +@router.get("/clients/{client_id}", response_class=HTMLResponse) +def client_detail(request: Request, client_id: int): + # Query client, 404 if not found + # Query printers filtered by client_id + # grouped = {client.name: list(query)} -- single-key dict for printer_list.html reuse + # driver_data for modal + # clients list for modal client dropdown + ... +``` + +From imptune/templates/partials/printer_list.html — current group header (to update): +```html +{% for client_name, printers in grouped.items() %} +
+

{{ client_name }}

+ ... +``` +Update to: +```html +

{{ client_name }}

+``` +OR: Pass grouped as dict of {client_id -> (client_name, printers)} — refactor the grouped structure. +RECOMMENDED APPROACH: Since printer_list.html receives `grouped` as {client_name: [printers]}, and `p.client` is available in each loop, extract the client_id from the first printer in the group: `{% set client_id = (printers[0].client_id if printers) %}`. This avoids changing the grouped data structure. + +For "Unassigned" group: client_id will be None/empty — no link, just plain text. + +From imptune/templates/partials/client_list.html — current client name cell: +```html +{{ c.name }} +``` +Update to: +```html +{{ c.name }} +``` + +From tests/test_printer_crud.py — scaffolded tests (RED from Plan 01, now going GREEN): +- test_client_detail_returns_200 +- test_client_detail_not_found +- test_client_links_in_printer_list + + + + + + + Task 1: GET /clients/{id} route + client_detail.html template + imptune/api/pages.py, imptune/templates/client_detail.html + + - GET /clients/{id} (existing client with printers) returns 200, contains client name, contains printer names assigned to that client + - GET /clients/{id} (existing client with no printers) returns 200, contains client name + - GET /clients/9999 returns 404 + These tests are the RED scaffolds from Plan 01 (test_client_detail_returns_200, test_client_detail_not_found) + + + **Step 1 — Add GET /clients/{client_id} route to imptune/api/pages.py:** + + Add after the existing clients_page route: + ```python + @router.get("/clients/{client_id}", response_class=HTMLResponse) + def client_detail(request: Request, client_id: int): + from imptune.db.models import Client, Driver, Printer + import json + + client = Client.get_or_none(Client.id == client_id) + if client is None: + return HTMLResponse( + content="

404 Not Found

Client not found.

", + status_code=404, + ) + + query = ( + Printer.select(Printer, Client) + .join(Client, JOIN.LEFT_OUTER) + .where(Printer.client == client_id) + .order_by(Printer.name) + ) + grouped = {client.name: list(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="client_detail.html", + context={ + "client": client, + "grouped": grouped, + "clients": clients, + "driver_data": driver_data, + }, + ) + ``` + + **Step 2 — Create imptune/templates/client_detail.html:** + + ```html + {% extends "base.html" %} + + {% block content %} +

{{ client.name }}

+

← All Clients

+ +
+

Printers

+ {% include "partials/printer_list.html" %} +
+ {% endblock %} + ``` + + This template reuses printer_list.html which already handles the Edit and Delete actions (from Plan 02). The `grouped`, `clients`, and `driver_data` context vars are all passed from the route handler so the printer list and edit modals work identically to /printers. +
+ + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_printer_crud.py -x -q -k "client_detail or client_not_found" 2>&1 | tail -10 + + test_client_detail_returns_200 and test_client_detail_not_found both GREEN. +
+ + + Task 2: Client name links in printer_list.html and client_list.html + + imptune/templates/partials/printer_list.html, + imptune/templates/partials/client_list.html + + + - GET /printers with a printer assigned to a client contains `href="/clients/{client_id}"` in the response HTML + - Client names in the client table are wrapped in anchor tags pointing to /clients/{id} + - "Unassigned" group header in printer list is plain text (no link — no client ID to link to) + This test is the RED scaffold from Plan 01 (test_client_links_in_printer_list) + + + **Step 1 — Update imptune/templates/partials/printer_list.html group headers:** + + Change the `

{{ client_name }}

` to: + ```html + {% set group_client_id = printers[0].client_id if printers else None %} + {% if group_client_id %} +

{{ client_name }}

+ {% else %} +

{{ client_name }}

+ {% endif %} + ``` + + This extracts the client_id from the first printer in the group. For "Unassigned" (client_id=None), the condition is False and plain text is rendered. + + Verify: the Jinja2 template uses `{% for client_name, printers in grouped.items() %}` — `printers` is available as the inner list, so `printers[0].client_id` is accessible. The `client_id` attribute is a Peewee FK field that returns the raw integer when accessed as `p.client_id` (not the FK object). + + **Step 2 — Update imptune/templates/partials/client_list.html:** + + Change: + ```html + {{ c.name }} + ``` + to: + ```html + {{ c.name }} + ``` + + That's the only change needed in client_list.html. +
+ + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_printer_crud.py -x -q -k "client_links" 2>&1 | tail -10 + Also full suite: pytest tests/ -x -q --ignore=tests/e2e + + + - test_client_links_in_printer_list GREEN + - Client names in client_list.html are wrapped in anchor tags + - Group headers in printer_list.html link to /clients/{id} for assigned clients + - "Unassigned" group header remains plain text + - All non-E2E tests GREEN + +
+ +
+ + +Run full non-E2E suite: +``` +cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/ -x -q --ignore=tests/e2e +``` +Expected: all GREEN — all UIE-01/02/03 scaffolded tests now GREEN. + +Run full E2E suite: +``` +cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/e2e/ -q +``` +Expected: all E2E tests GREEN (port_autofill + printer_edit + theme_toggle + i18n_toggle). + +Verify UIE-03 integration test coverage: +``` +pytest tests/test_printer_crud.py -v -k "client_detail or client_not_found or client_links" +``` +Expected: 3 tests GREEN. + + + +- GET /clients/{id} returns 200 with client name and filtered printer list +- GET /clients/9999 returns 404 +- Client names in printer list group headers link to /clients/{id} +- Client names in clients table link to /clients/{id} +- "Unassigned" group header is plain text (no dead link) +- Edit and Delete actions on /clients/{id} page work via the reused printer_list partial +- All Phase 11 integration tests GREEN (UIE-01/02/03 scaffolds fully resolved) +- Full test suite (including E2E) GREEN + + + +After completion, create `.planning/phases/11-ui-enhancements/11-04-SUMMARY.md` +