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>
This commit is contained in:
+17
-15
@@ -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 | |
|
||||
|
||||
@@ -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 <form> (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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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/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
|
||||
|
||||
<interfaces>
|
||||
<!-- Key patterns the executor needs. Extracted from live codebase. -->
|
||||
|
||||
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 %}
|
||||
<h1>Printers</h1>
|
||||
<section>
|
||||
<h2>Add Printer</h2>
|
||||
{% include "partials/printer_form.html" %}
|
||||
</section>
|
||||
<section>
|
||||
<h2>Printer Library</h2>
|
||||
{% include "partials/printer_list.html" %}
|
||||
</section>
|
||||
{% 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)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Wave 0 — Add integration test scaffolds for UIE-01, UIE-02, UIE-03</name>
|
||||
<files>tests/test_printer_crud.py</files>
|
||||
<behavior>
|
||||
- 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)
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
Expected: all new tests FAIL (RED state — routes/templates do not exist yet).
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: UIE-02 — Separate form from library (GET /printers/new + POST redirect)</name>
|
||||
<files>
|
||||
imptune/api/pages.py,
|
||||
imptune/api/printers.py,
|
||||
imptune/templates/printers.html,
|
||||
imptune/templates/printers_new.html
|
||||
</files>
|
||||
<behavior>
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
**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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
Also run: pytest tests/test_printer_crud.py -x -q 2>&1 | tail -10 (all tests GREEN)
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-ui-enhancements/11-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -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>
|
||||
@@ -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 <html> 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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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
|
||||
|
||||
<interfaces>
|
||||
<!-- Key patterns from live codebase and RESEARCH.md. -->
|
||||
|
||||
Current base.html structure (full file):
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ImpTune</title>
|
||||
<link rel="stylesheet" href="/static/pico.min.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script defer src="/static/alpine.min.js"></script>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<strong>ImpTune</strong>
|
||||
</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/" ...>Dashboard</a></li>
|
||||
<li><a href="/drivers" ...>Drivers</a></li>
|
||||
<li><a href="/printers" ...>Printers</a></li>
|
||||
<li><a href="/clients" ...>Clients</a></li>
|
||||
<li><a href="/packages" ...>Packages</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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 `<script defer src="/static/alpine.min.js">` line — inline scripts without defer run synchronously, so they execute before any deferred scripts.
|
||||
|
||||
Pico CSS data-theme: already on <html data-theme="auto"> — just toggle the attribute value.
|
||||
|
||||
Translation keys needed (full inventory of static UI strings in templates):
|
||||
- dashboard, drivers, printers, clients, packages (nav labels)
|
||||
- add_printer (button on /printers), add_client (button on clients.html)
|
||||
- save_printer (submit button on add/edit forms), edit, delete, cancel, save
|
||||
- upload_driver (upload button), no_printers, no_clients
|
||||
- printer_name, ip_address, port_name, driver, duplex_mode, color_mode, paper_size, collate, client
|
||||
- one_sided, long_edge, short_edge (duplex options)
|
||||
- color, color_mode_label (checkbox), collate_label
|
||||
- edit_printer (modal heading), close
|
||||
- add_client_heading, client_list_heading, add_printer_heading, printer_library_heading
|
||||
- theme_light, theme_dark, theme_auto (optional — for aria-labels)
|
||||
|
||||
Note: only static chrome strings need translation in this phase. Server-rendered dynamic values (printer names, error messages) stay in English — this is explicitly out of scope per RESEARCH.md open question 3.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Alpine.js stores + top-right controls in base.html</name>
|
||||
<files>imptune/templates/base.html, tests/test_static.py</files>
|
||||
<action>
|
||||
**Step 1 — Add Alpine.js store definition script to base.html:**
|
||||
|
||||
Add the following script block BEFORE the `<script defer src="/static/alpine.min.js">` line (inline scripts run before defer scripts):
|
||||
|
||||
```html
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Theme store: cycles Light -> Dark -> System, persists in localStorage
|
||||
Alpine.store('theme', {
|
||||
current: localStorage.getItem('imptune_theme') || 'auto',
|
||||
icons: { light: '☀', dark: '☾', auto: '◑' },
|
||||
init() {
|
||||
document.documentElement.setAttribute('data-theme', this.current);
|
||||
},
|
||||
cycle() {
|
||||
const order = ['light', 'dark', 'auto'];
|
||||
this.current = order[(order.indexOf(this.current) + 1) % order.length];
|
||||
localStorage.setItem('imptune_theme', this.current);
|
||||
document.documentElement.setAttribute('data-theme', this.current);
|
||||
}
|
||||
});
|
||||
|
||||
// i18n store: FR/EN toggle, persists in localStorage
|
||||
Alpine.store('i18n', {
|
||||
lang: localStorage.getItem('imptune_lang') || 'fr',
|
||||
t(key) {
|
||||
return (this.translations[this.lang] || {})[key] || key;
|
||||
},
|
||||
toggle() {
|
||||
this.lang = this.lang === 'fr' ? 'en' : 'fr';
|
||||
localStorage.setItem('imptune_lang', this.lang);
|
||||
},
|
||||
translations: {
|
||||
fr: {
|
||||
dashboard: 'Tableau de bord',
|
||||
drivers: 'Pilotes',
|
||||
printers: 'Imprimantes',
|
||||
clients: 'Clients',
|
||||
packages: 'Paquets',
|
||||
add_printer: 'Ajouter une imprimante',
|
||||
add_client: 'Ajouter un client',
|
||||
printer_library: 'Biblioth\u00e8que d\u2019imprimantes',
|
||||
edit: 'Modifier',
|
||||
delete: 'Supprimer',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
upload_driver: 'T\u00e9l\u00e9charger un pilote',
|
||||
printer_name: 'Nom de l\u2019imprimante',
|
||||
ip_address: 'Adresse IP',
|
||||
port_name: 'Nom du port',
|
||||
driver: 'Pilote',
|
||||
duplex_mode: 'Mode recto-verso',
|
||||
one_sided: 'Recto simple',
|
||||
long_edge: 'Grand c\u00f4t\u00e9',
|
||||
short_edge: 'Petit c\u00f4t\u00e9',
|
||||
color_mode: 'Mode couleur',
|
||||
paper_size: 'Format papier',
|
||||
collate: 'Assembler',
|
||||
client: 'Client',
|
||||
edit_printer: 'Modifier l\u2019imprimante',
|
||||
no_printers: 'Aucune imprimante configur\u00e9e.',
|
||||
no_clients: 'Aucun client configur\u00e9.',
|
||||
client_list: 'Liste des clients',
|
||||
name: 'Nom',
|
||||
created: 'Cr\u00e9\u00e9 le',
|
||||
back_to_printers: 'Retour aux imprimantes',
|
||||
theme_label: 'Th\u00e8me',
|
||||
lang_label: 'FR'
|
||||
},
|
||||
en: {
|
||||
dashboard: 'Dashboard',
|
||||
drivers: 'Drivers',
|
||||
printers: 'Printers',
|
||||
clients: 'Clients',
|
||||
packages: 'Packages',
|
||||
add_printer: 'Add Printer',
|
||||
add_client: 'Add Client',
|
||||
printer_library: 'Printer Library',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
upload_driver: 'Upload Driver',
|
||||
printer_name: 'Printer Name',
|
||||
ip_address: 'IP Address',
|
||||
port_name: 'Port Name',
|
||||
driver: 'Driver',
|
||||
duplex_mode: 'Duplex Mode',
|
||||
one_sided: 'One-Sided',
|
||||
long_edge: 'Long Edge',
|
||||
short_edge: 'Short Edge',
|
||||
color_mode: 'Color Mode',
|
||||
paper_size: 'Paper Size',
|
||||
collate: 'Collate',
|
||||
client: 'Client',
|
||||
edit_printer: 'Edit Printer',
|
||||
no_printers: 'No printers configured yet.',
|
||||
no_clients: 'No clients configured yet.',
|
||||
client_list: 'Client List',
|
||||
name: 'Name',
|
||||
created: 'Created',
|
||||
back_to_printers: 'Back to Printers',
|
||||
theme_label: 'Theme',
|
||||
lang_label: 'EN'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Step 2 — Add top-right controls area to the layout in base.html:**
|
||||
|
||||
Inside the `<div class="layout">`, add a top-right controls bar above the main content area. Modify the layout to include a controls area:
|
||||
|
||||
```html
|
||||
<div class="layout">
|
||||
<nav class="sidebar">
|
||||
<!-- existing sidebar content — update nav link labels to use x-text -->
|
||||
<div class="sidebar-brand">
|
||||
<strong>ImpTune</strong>
|
||||
</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}
|
||||
x-text="$store.i18n.t('dashboard')">Dashboard</a></li>
|
||||
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}
|
||||
x-text="$store.i18n.t('drivers')">Drivers</a></li>
|
||||
<li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}
|
||||
x-text="$store.i18n.t('printers')">Printers</a></li>
|
||||
<li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}
|
||||
x-text="$store.i18n.t('clients')">Clients</a></li>
|
||||
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}
|
||||
x-text="$store.i18n.t('packages')">Packages</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="main-wrapper">
|
||||
<header class="topbar">
|
||||
<div class="topbar-controls">
|
||||
<!-- Theme toggle button: cycles Light -> Dark -> System -->
|
||||
<button class="secondary outline"
|
||||
x-data
|
||||
x-html="$store.theme.icons[$store.theme.current]"
|
||||
:aria-label="$store.theme.current"
|
||||
@click="$store.theme.cycle()"
|
||||
title="Toggle theme">◑</button>
|
||||
<!-- Language toggle button -->
|
||||
<button class="secondary outline"
|
||||
x-data
|
||||
x-text="$store.i18n.t('lang_label')"
|
||||
@click="$store.i18n.toggle()"
|
||||
title="Toggle language">FR</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_static.py -x -q -k "theme_toggle_present" 2>&1 | tail -10</automated>
|
||||
Also: pytest tests/ -x -q --ignore=tests/e2e (full non-E2E suite GREEN)
|
||||
</verify>
|
||||
<done>
|
||||
- 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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: E2E tests for theme toggle and language toggle</name>
|
||||
<files>tests/e2e/test_theme_toggle.py, tests/e2e/test_i18n_toggle.py</files>
|
||||
<action>
|
||||
**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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- 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.
|
||||
</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 (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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-ui-enhancements/11-03-SUMMARY.md`
|
||||
</output>
|
||||
@@ -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 <a href='/clients/{c.id}'>"
|
||||
contains: "/clients/"
|
||||
- path: "imptune/templates/partials/printer_list.html"
|
||||
provides: "Group headers with client name as <a href='/clients/{client_id}'>"
|
||||
contains: "/clients/"
|
||||
key_links:
|
||||
- from: "imptune/templates/partials/client_list.html"
|
||||
to: "/clients/{c.id}"
|
||||
via: "<a href='/clients/{{ c.id }}'>{{ c.name }}</a>"
|
||||
pattern: "href.*clients.*c\\.id"
|
||||
- from: "imptune/templates/partials/printer_list.html"
|
||||
to: "/clients/{client_id}"
|
||||
via: "<a href='/clients/{id}'>{{ client_name }}</a> in group header <h3>"
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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-02-SUMMARY.md
|
||||
@.planning/phases/11-ui-enhancements/11-03-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key patterns extracted from codebase. -->
|
||||
|
||||
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() %}
|
||||
<section>
|
||||
<h3>{{ client_name }}</h3>
|
||||
...
|
||||
```
|
||||
Update to:
|
||||
```html
|
||||
<h3><a href="/clients/{{ client_id_map[client_name] }}">{{ client_name }}</a></h3>
|
||||
```
|
||||
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
|
||||
<td>{{ c.name }}</td>
|
||||
```
|
||||
Update to:
|
||||
```html
|
||||
<td><a href="/clients/{{ c.id }}">{{ c.name }}</a></td>
|
||||
```
|
||||
|
||||
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
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: GET /clients/{id} route + client_detail.html template</name>
|
||||
<files>imptune/api/pages.py, imptune/templates/client_detail.html</files>
|
||||
<behavior>
|
||||
- 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)
|
||||
</behavior>
|
||||
<action>
|
||||
**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="<h1>404 Not Found</h1><p>Client not found.</p>",
|
||||
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 %}
|
||||
<h1>{{ client.name }}</h1>
|
||||
<p><a href="/clients">← All Clients</a></p>
|
||||
|
||||
<section>
|
||||
<h2>Printers</h2>
|
||||
{% include "partials/printer_list.html" %}
|
||||
</section>
|
||||
{% 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>test_client_detail_returns_200 and test_client_detail_not_found both GREEN.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Client name links in printer_list.html and client_list.html</name>
|
||||
<files>
|
||||
imptune/templates/partials/printer_list.html,
|
||||
imptune/templates/partials/client_list.html
|
||||
</files>
|
||||
<behavior>
|
||||
- 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)
|
||||
</behavior>
|
||||
<action>
|
||||
**Step 1 — Update imptune/templates/partials/printer_list.html group headers:**
|
||||
|
||||
Change the `<h3>{{ client_name }}</h3>` to:
|
||||
```html
|
||||
{% set group_client_id = printers[0].client_id if printers else None %}
|
||||
{% if group_client_id %}
|
||||
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
|
||||
{% else %}
|
||||
<h3>{{ client_name }}</h3>
|
||||
{% 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
|
||||
<td>{{ c.name }}</td>
|
||||
```
|
||||
to:
|
||||
```html
|
||||
<td><a href="/clients/{{ c.id }}">{{ c.name }}</a></td>
|
||||
```
|
||||
|
||||
That's the only change needed in client_list.html.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_printer_crud.py -x -q -k "client_links" 2>&1 | tail -10</automated>
|
||||
Also full suite: pytest tests/ -x -q --ignore=tests/e2e
|
||||
</verify>
|
||||
<done>
|
||||
- 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
|
||||
</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 — 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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-ui-enhancements/11-04-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user