05/26 Update form editor
This commit is contained in:
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
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'));
|
||||
|
||||
Reference in New Issue
Block a user