From 52dd6c3e32e2f1bb651de3d3bb32b96813b043b2 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 15 May 2026 05:23:25 +0000 Subject: [PATCH] feat(locations): drag-to-reorder + three-dot kebab menu on cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project location cards now reorderable via drag-and-drop, and the four inline action buttons (Unassign/Edit/Remove/Delete) collapse into a single three-dot kebab menu — much cleaner card layout, especially for projects with many locations. Data - MonitoringLocation.sort_order: nullable Integer, default 0. Migration `migrate_add_location_sort_order.py` adds the column and seeds existing rows with sort_order = alphabetical index per project (so the post-migration display order matches what operators see today — no surprise reordering). - get_project_locations + locations-json: ORDER BY sort_order, name. - Location-create: assigns max(sort_order) + 1 so new locations land at the END of the list rather than being interleaved alphabetically. Reorder endpoint - POST /api/projects/{p}/locations/reorder Body: { location_ids: [uuid, uuid, ...] } Validates: all ids belong to this project; raises 404 on missing. Applies 0-indexed sort_order matching the provided order. UI changes (templates/partials/projects/location_list.html) - Active cards get a draggable="true" attribute + native HTML5 drag/drop handlers. Drop reorders the DOM immediately, then posts the new order to the reorder endpoint. Drop-zone visual feedback (orange ring on hover, opacity on source during drag). - Six-dot drag handle icon on the left of each active card; whole card body is the drag source but the handle is the visual cue. - Right side: small Assign pill (only shown when unassigned) + three-dot kebab menu containing Unassign/Edit/Remove/Delete. Click ⋮ to toggle; click outside or Escape to close. Only one menu open at a time. - Removed locations are NOT draggable (their order is historical) and keep their existing Restore button visible. The card also shows "{N} events" instead of "Sessions: N" when the location_type is vibration AND the backend passes event_count in the payload — which lands in commit 2 of this redesign. Co-Authored-By: Claude Opus 4.7 --- backend/migrate_add_location_sort_order.py | 76 +++++ backend/models.py | 6 + backend/routers/project_locations.py | 64 ++++- .../partials/projects/location_list.html | 260 ++++++++++++++---- 4 files changed, 352 insertions(+), 54 deletions(-) create mode 100644 backend/migrate_add_location_sort_order.py diff --git a/backend/migrate_add_location_sort_order.py b/backend/migrate_add_location_sort_order.py new file mode 100644 index 0000000..cc5c2c1 --- /dev/null +++ b/backend/migrate_add_location_sort_order.py @@ -0,0 +1,76 @@ +""" +Migration: add `sort_order` column to `monitoring_locations` and seed +existing rows. + +Lets operators reorder location cards via drag-and-drop on the project +detail page. Lower sort_order renders first; ties fall back to name. + +Seed strategy: for each existing project, assign sort_order = 0, 1, 2, … +to its locations in their current alphabetical-by-name order. After +this migration, the visible card order on every existing project will +be unchanged. + +Idempotent — safe to re-run. Non-destructive — adds only. + +Run with: + docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py +""" + +import os +import sqlite3 + +DB_PATH = "./data/seismo_fleet.db" + + +def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool: + cur.execute(f"PRAGMA table_info({table})") + return any(row[1] == column for row in cur.fetchall()) + + +def migrate_database() -> None: + if not os.path.exists(DB_PATH): + print(f"Database not found at {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + added_column = False + if not _has_column(cur, "monitoring_locations", "sort_order"): + cur.execute("ALTER TABLE monitoring_locations ADD COLUMN sort_order INTEGER DEFAULT 0") + added_column = True + print(" Added column: monitoring_locations.sort_order") + + # Seed: for each project, set sort_order to its alphabetical index. + # Re-runs are harmless — operator-edited orderings can be re-seeded by + # passing FORCE_RESEED=1, but the default behavior leaves existing + # nonzero sort_order values alone so we don't clobber user choices. + force_reseed = os.environ.get("FORCE_RESEED") == "1" + if added_column or force_reseed: + cur.execute("SELECT DISTINCT project_id FROM monitoring_locations") + projects = [r[0] for r in cur.fetchall()] + seeded = 0 + for project_id in projects: + cur.execute( + "SELECT id FROM monitoring_locations WHERE project_id = ? ORDER BY name", + (project_id,), + ) + for idx, (loc_id,) in enumerate(cur.fetchall()): + cur.execute( + "UPDATE monitoring_locations SET sort_order = ? WHERE id = ?", + (idx, loc_id), + ) + seeded += 1 + print(f" Seeded sort_order for {seeded} location(s) across {len(projects)} project(s).") + else: + print(" monitoring_locations.sort_order already present — leaving existing values alone.") + print(" (Set FORCE_RESEED=1 to re-seed by alphabetical order.)") + + conn.commit() + conn.close() + + +if __name__ == "__main__": + print("Running migration: add sort_order to monitoring_locations") + migrate_database() + print("Done.") diff --git a/backend/models.py b/backend/models.py index 8e17197..380656b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -243,6 +243,12 @@ class MonitoringLocation(Base): removed_at = Column(DateTime, nullable=True) removal_reason = Column(Text, nullable=True) + # Display order within the project's location list. Operators can + # drag-and-drop to reorder cards on the project detail page. Lower + # values render first; ties fall back to name (alphabetical). Seeded + # to alphabetical-index on migration; new locations get max+1. + sort_order = Column(Integer, default=0, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 7059192..8c89793 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -151,7 +151,8 @@ async def get_project_locations( if location_type: query = query.filter_by(location_type=location_type) - locations = query.order_by(MonitoringLocation.name).all() + # Order by operator-set sort_order, then name as a stable tie-breaker. + locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all() # Enrich with assignment info, splitting active vs removed. active_data: list = [] @@ -224,7 +225,7 @@ async def get_project_locations_json( if not include_removed: query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711 - locations = query.order_by(MonitoringLocation.name).all() + locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all() return [ { @@ -256,6 +257,13 @@ async def create_location( form_data = await request.form() + # Compute next sort_order so new locations land at the END of the + # project's list rather than getting interleaved alphabetically. + from sqlalchemy import func + max_sort = db.query(func.max(MonitoringLocation.sort_order))\ + .filter_by(project_id=project_id).scalar() + next_sort_order = (max_sort or 0) + 1 if max_sort is not None else 0 + location = MonitoringLocation( id=str(uuid.uuid4()), project_id=project_id, @@ -265,6 +273,7 @@ async def create_location( coordinates=form_data.get("coordinates"), address=form_data.get("address"), location_metadata=form_data.get("location_metadata"), # JSON string + sort_order=next_sort_order, ) db.add(location) @@ -356,6 +365,57 @@ async def delete_location( return {"success": True, "message": "Location deleted successfully"} +@router.post("/locations/reorder") +async def reorder_locations( + project_id: str, + request: Request, + db: Session = Depends(get_db), +): + """ + Persist a new sort order for a project's monitoring locations. + + Body JSON: { "location_ids": [uuid, uuid, ...] } + The list MUST contain location ids in the desired display order. + Locations not included in the list keep their current sort_order + (useful for the "active locations only — leave removed alone" + drag-and-drop UX). + + Updates `sort_order` to the index of each id in the list. Ties + between included and excluded locations fall back to the existing + sort_order. + """ + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + ids = payload.get("location_ids") or [] + if not isinstance(ids, list) or len(ids) == 0: + raise HTTPException(status_code=400, detail="location_ids must be a non-empty list") + + # Fetch only the locations being reordered and validate ownership. + locations = db.query(MonitoringLocation).filter( + MonitoringLocation.project_id == project_id, + MonitoringLocation.id.in_(ids), + ).all() + + found_ids = {l.id for l in locations} + missing = [i for i in ids if i not in found_ids] + if missing: + raise HTTPException( + status_code=404, + detail=f"Some locations not found in this project: {missing[:3]}…", + ) + + # Apply 0-indexed sort_order matching the operator's chosen order. + by_id = {l.id: l for l in locations} + for idx, loc_id in enumerate(ids): + by_id[loc_id].sort_order = idx + + db.commit() + return {"success": True, "reordered": len(ids)} + + @router.post("/locations/{location_id}/remove") async def remove_location( project_id: str, diff --git a/templates/partials/projects/location_list.html b/templates/partials/projects/location_list.html index f9279c2..d5ccecb 100644 --- a/templates/partials/projects/location_list.html +++ b/templates/partials/projects/location_list.html @@ -1,7 +1,18 @@ - + {% if not active_locations and not removed_locations %}
@@ -12,69 +23,110 @@
{% else %} -{# ─── Active locations ─── #} +{# ─── Active locations (draggable) ─── #} {% if active_locations %} -
+
{% for item in active_locations %} -
+
+
-
-
+ +
+
+ + + +
+
{{ item.location.name }} + {% if item.location.description %} +

{{ item.location.description }}

+ {% endif %} + {% if item.location.address %} +

{{ item.location.address }}

+ {% endif %} + {% if item.location.coordinates %} +

{{ item.location.coordinates }}

+ {% endif %} + +
+ {% if item.event_count is defined and item.location.location_type == 'vibration' %} + {{ "{:,}".format(item.event_count) }} event{{ '' if item.event_count == 1 else 's' }} + {% else %} + Sessions: {{ item.session_count }} + {% endif %} + {% if item.assignment and item.assigned_unit %} + Assigned: {{ item.assigned_unit.id }} + {% else %} + No active assignment + {% endif %} +
- {% if item.location.description %} -

{{ item.location.description }}

- {% endif %} - {% if item.location.address %} -

{{ item.location.address }}

- {% endif %} - {% if item.location.coordinates %} -

{{ item.location.coordinates }}

- {% endif %}
-
- {% if item.assignment %} - - {% else %} + +
+ {% if not item.assignment %} + {% endif %} - - - -
-
-
- Sessions: {{ item.session_count }} - {% if item.assignment and item.assigned_unit %} - Assigned: {{ item.assigned_unit.id }} - {% else %} - No active assignment - {% endif %} + +
+ + + +
+
{% endfor %} @@ -135,7 +187,11 @@
- Historical sessions: {{ item.session_count }} + {% if item.event_count is defined and item.location.location_type == 'vibration' %} + {{ "{:,}".format(item.event_count) }} historical event{{ '' if item.event_count == 1 else 's' }} + {% else %} + Historical sessions: {{ item.session_count }} + {% endif %}
{% endfor %} @@ -144,3 +200,103 @@ {% endif %} {% endif %} + + +