05/26 Update form editor

This commit is contained in:
2026-05-26 13:13:28 -04:00
parent 5d6019ea7f
commit a2eb08807f
3 changed files with 53 additions and 21 deletions
+12 -1
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
> **Last reviewed:** May 2026 (Phase 19 complete + post-phase-19 improvements: security hardening, inspection UX, inspector dashboard widget, bulk verification, scheduled issues digest with SLA grouping, customer read-only issue portal, inspector contract scoping)
> **Last reviewed:** May 2026 (Phase 19 complete + post-phase-19 improvements: security hardening, inspection UX, inspector dashboard widget, bulk verification, scheduled issues digest with SLA grouping, customer read-only issue portal, inspector contract scoping, contract visibility in issues/inspections UI, Contract→Facility cascade filters)
---
@@ -598,6 +598,14 @@ Always use `user.display_name` in templates — never `.username` for display pu
`view.html` shows `photo_path` and `mobile_photo_paths` together under the **"Photo Evidence"** heading using a `d-flex flex-wrap gap-2` grid. `result_photos` (resolution photos) appear separately under **"Resolution Details"**. Do not merge these sections — they have different semantic meaning.
### Contract → Facility Cascade (filter bars and create form)
The Contract selector is always a plain HTML `<select>` (never a WTForms field). On `change` it calls `GET /inspections/facilities_for_project/<project_id>` and replaces the Facility `<option>` list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when `contract_id` is in the query string.
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar).
The issues list and inspections list both accept a `contract_id` query param that filters the DB query to facilities belonging to that contract (`facility.project_id == contract_id`) and narrows the facility dropdown in the rendered HTML.
### Inspection Execute Page — UX Patterns
- **Photo upload-on-select**: `uploadPhotoField(input)` fires immediately on `<input type="file">` change. XHR to `POST /<id>/upload-photo`. On success, the server path is written to `<input type="hidden" id="field_<fid>_server_path">` and a `<img id="thumb_<fid>">` is shown.
@@ -693,6 +701,9 @@ timeout = 30
| 57 | **Inspector contract scoping: `get_inspector_scope()` — strict, no fallback** | Inspectors with NO `InspectorAssignment` rows see nothing (empty list, not `None`). Returns `None` only for non-inspector roles. All routes and API endpoints that currently filter by `inspector_id` or `assigned_to/reported_by` must instead filter by the facility list returned by `get_inspector_scope()`. |
| 58 | **Inspector scope covers all data in contracted facilities, not just own work** | Facility list, inspection list, issue list — all scoped to contracted facilities. Dashboard personal stats (today's work, avg score, trend) additionally filter by `inspector_id` so the productivity view stays personal. Issues show ALL facility issues, not just assigned ones. |
| 59 | **`assign_inspector_contracts` route replaces the entire assignment set on POST** | The form sends the full checked list; existing assignments not in the POST body are deleted, new ones are inserted. Callers must always POST the complete desired set, not a diff. The page includes Select All / Deselect All buttons (JS-only, no server round-trip) and a live "N assigned" badge that updates on each checkbox change. |
| 60 | **`flag_issue` offcanvas form must include `<input type="hidden" name="facility_id">`** | `IssueForm.facility_id` has `DataRequired()`. The hand-written offcanvas form in `execute.html` is not rendered by WTForms, so it must explicitly send `facility_id`. Without it, `form.validate_on_submit()` silently returns `False`, the server responds `200 OK` with the `flag_issue.html` template, and the JS treats `res.ok` as success — no issue is ever saved. Fix: `<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">` inside `#flagIssueForm`. |
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
---
+2 -2
View File
@@ -32,7 +32,7 @@
display: grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: 52px; /* matches editor CELL_H — keeps proportions tight */
gap: 4px 8px;
gap: 8px;
width: max-content;
}
@@ -42,7 +42,7 @@
Falls back to a safe 100% - padding calculation if JS hasn't run yet. */
@media (max-width: 1194px) {
.form-grid {
--fg-gap: 4px;
--fg-gap: 8px;
--fg-w: calc(100% - 2rem); /* JS overrides this with measured px value */
--fg-cell: calc((var(--fg-w) - 11 * var(--fg-gap)) / 12);
grid-template-columns: repeat(12, var(--fg-cell));
+38 -17
View File
@@ -174,6 +174,7 @@
border-bottom: 1px solid var(--border);
cursor: move; flex-shrink: 0;
user-select: none;
touch-action: none; /* allow pointerdown drag without triggering scroll */
}
.fcard-badge {
font-size: .6rem; font-weight: 700;
@@ -229,11 +230,12 @@
/* resize handle — bottom-right */
.resize-h {
position: absolute; bottom: 0; right: 0;
width: 18px; height: 18px;
width: 24px; height: 24px; /* larger touch target on iPad */
cursor: se-resize; z-index: 30;
display: flex; align-items: center; justify-content: center;
color: #cbd5e1; font-size: .65rem;
transition: color var(--tr);
touch-action: none; /* allow pointerdown drag without triggering scroll */
}
.fcard.sel .resize-h { color: var(--accent); }
@@ -651,8 +653,8 @@ function buildCard(f) {
<div class="resize-h" title="Drag to resize"><i class="bi bi-arrows-angle-expand"></i></div>
`;
// click to select
card.addEventListener('mousedown', e => {
// tap / click to select
card.addEventListener('pointerdown', e => {
if (e.target.closest('.fact') || e.target.closest('.resize-h')) return;
selectField(f.id);
});
@@ -661,28 +663,27 @@ function buildCard(f) {
card.querySelector('[data-act="dup"]').addEventListener('click', e => { e.stopPropagation(); dupField(f.id); });
card.querySelector('[data-act="del"]').addEventListener('click', e => { e.stopPropagation(); delField(f.id); });
// move via header
card.querySelector('.fcard-head').addEventListener('mousedown', e => {
// move via header (pointer events cover mouse + touch + stylus)
card.querySelector('.fcard-head').addEventListener('pointerdown', e => {
if (e.target.closest('.fact')) return;
e.preventDefault();
selectField(f.id);
const rect = surface.getBoundingClientRect();
const cRect = card.getBoundingClientRect();
moveState = {
id: f.id,
offX: e.clientX - cRect.left,
offY: e.clientY - cRect.top,
surfLeft: rect.left,
surfTop: rect.top,
};
card.setPointerCapture(e.pointerId); // keeps pointermove firing even if pointer leaves card
card.classList.add('moving');
showPreview(f);
});
// resize
card.querySelector('.resize-h').addEventListener('mousedown', e => {
// resize (pointer events cover mouse + touch + stylus)
card.querySelector('.resize-h').addEventListener('pointerdown', e => {
e.preventDefault(); e.stopPropagation();
selectField(f.id);
card.querySelector('.resize-h').setPointerCapture(e.pointerId);
resizeState = {
id: f.id,
startX: e.clientX, startY: e.clientY,
@@ -1051,8 +1052,8 @@ function dupField(id) {
// ═══════════════════════════════════════════════════════════════════════════
// MOUSE MOVE + RESIZE
// ═══════════════════════════════════════════════════════════════════════════
document.addEventListener('mousemove', e => {
// move ghost label
document.addEventListener('pointermove', e => {
// move ghost label (desktop drag only)
if (ghostEl.style.display !== 'none') {
ghostEl.style.left = (e.clientX + 12) + 'px';
ghostEl.style.top = (e.clientY + 10) + 'px';
@@ -1060,8 +1061,10 @@ document.addEventListener('mousemove', e => {
if (moveState) {
const f = fields.find(f => f.id === moveState.id); if (!f) return;
const x = e.clientX - moveState.surfLeft - moveState.offX;
const y = e.clientY - moveState.surfTop - moveState.offY;
// Recalculate rect live — accounts for canvas-wrap scroll during touch drag
const sr = surface.getBoundingClientRect();
const x = e.clientX - sr.left - moveState.offX;
const y = e.clientY - sr.top - moveState.offY;
const { col, row } = pxCell(x, y);
const newCol = Math.max(1, Math.min(COLS - f.colSpan + 1, col));
const newRow = Math.max(1, row);
@@ -1094,7 +1097,7 @@ document.addEventListener('mousemove', e => {
}
});
document.addEventListener('mouseup', () => {
document.addEventListener('pointerup', () => {
if (moveState) {
surface.querySelector(`[data-id="${moveState.id}"]`)?.classList.remove('moving');
hidePreview(); renderProperties(); moveState = null;
@@ -1106,6 +1109,7 @@ document.addEventListener('mouseup', () => {
// PALETTE DRAG → CANVAS (HTML5 drag API)
// ═══════════════════════════════════════════════════════════════════════════
document.querySelectorAll('.pal-item').forEach(item => {
// Desktop: HTML5 drag-to-canvas
item.addEventListener('dragstart', e => {
palDragType = item.dataset.ftype;
e.dataTransfer.setData('ftype', item.dataset.ftype);
@@ -1119,6 +1123,23 @@ document.querySelectorAll('.pal-item').forEach(item => {
ghostEl.style.display = 'none';
item.classList.remove('dragging-src');
});
// Touch / iPad: tap palette item to append field at next available row
// iOS Safari does not support HTML5 drag API, so drag-to-canvas never fires.
let _tapStart = null;
item.addEventListener('pointerdown', e => {
if (e.pointerType !== 'touch') return;
_tapStart = { x: e.clientX, y: e.clientY };
});
item.addEventListener('pointerup', e => {
if (e.pointerType !== 'touch' || !_tapStart) return;
const moved = Math.hypot(e.clientX - _tapStart.x, e.clientY - _tapStart.y);
_tapStart = null;
if (moved > 12) return; // swipe in palette strip — not a tap
const maxRow = fields.reduce((m, f) => Math.max(m, f.row + f.rowSpan - 1), 0);
addField(item.dataset.ftype, 1, maxRow + 1);
});
item.addEventListener('pointercancel', () => { _tapStart = null; });
});
document.addEventListener('dragover', e => {
@@ -1159,8 +1180,8 @@ surface.addEventListener('drop', e => {
addField(type, Math.max(1, Math.min(COLS - cs + 1, col)), Math.max(1, row));
});
// deselect on bare canvas click
surface.addEventListener('mousedown', e => {
// deselect on bare canvas tap/click
surface.addEventListener('pointerdown', e => {
if (e.target === surface || e.target.closest('.grid-hint')) {
selectedId = null;
surface.querySelectorAll('.fcard').forEach(c => c.classList.remove('sel'));