From 5df69fe956652d52191b81cc460a959f3203d343 Mon Sep 17 00:00:00 2001
From: Derick M <58572875+TurtIeSocks@users.noreply.github.com>
Date: Sun, 23 Apr 2023 21:21:11 -0400
Subject: [PATCH 1/2] enhanced geofence filtering

---
 .../pages/admin/geofence/GeofenceFilter.tsx   | 73 +++++++++++++++++
 .../src/pages/admin/geofence/GeofenceList.tsx |  5 +-
 server/api/src/lib.rs                         |  1 +
 server/api/src/private/admin.rs               | 73 +++++------------
 server/model/src/api/args.rs                  | 47 +++++++++++
 server/model/src/db/geofence.rs               | 81 +++++++++++++++----
 server/model/src/db/project.rs                | 29 ++++---
 server/model/src/db/property.rs               | 29 ++++---
 server/model/src/db/route.rs                  | 22 +++--
 server/model/src/db/tile_server.rs            | 21 +++--
 10 files changed, 256 insertions(+), 125 deletions(-)
 create mode 100644 client/src/pages/admin/geofence/GeofenceFilter.tsx

diff --git a/client/src/pages/admin/geofence/GeofenceFilter.tsx b/client/src/pages/admin/geofence/GeofenceFilter.tsx
new file mode 100644
index 00000000..7ea1a7f2
--- /dev/null
+++ b/client/src/pages/admin/geofence/GeofenceFilter.tsx
@@ -0,0 +1,73 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import * as React from 'react'
+import {
+  // SavedQueriesList,
+  FilterLiveSearch,
+  FilterList,
+  FilterListItem,
+  useGetList,
+} from 'react-admin'
+import { useQuery } from 'react-query'
+import { Card, CardContent } from '@mui/material'
+import AutoModeIcon from '@mui/icons-material/AutoMode'
+import AccountTree from '@mui/icons-material/AccountTree'
+import MapIcon from '@mui/icons-material/Map'
+import SupervisedUserCircleIcon from '@mui/icons-material/SupervisedUserCircle'
+
+import { useStatic } from '@hooks/useStatic'
+import { RDM_FENCES, UNOWN_FENCES } from '@assets/constants'
+import { KojiGeofence, KojiProject, KojiResponse } from '@assets/types'
+import { fetchWrapper } from '@services/fetches'
+
+export function GeofenceFilter() {
+  const { scannerType } = useStatic.getState()
+  const projectData = useGetList<KojiProject>('project')
+  const { data } = useQuery('parents', () =>
+    fetchWrapper<KojiResponse<KojiGeofence[]>>(
+      '/internal/admin/geofence/parent',
+    ),
+  )
+  return (
+    <Card sx={{ order: -1, mt: 9, width: 200 }}>
+      <CardContent>
+        {/* <SavedQueriesList /> */}
+        <FilterLiveSearch />
+        <FilterList label="Project" icon={<AccountTree />}>
+          {(projectData?.data || []).map((project) => (
+            <FilterListItem
+              key={project.id}
+              label={project.name}
+              value={{ project: project.id }}
+            />
+          ))}
+        </FilterList>
+        <FilterList label="Parent" icon={<SupervisedUserCircleIcon />}>
+          {(data?.data || []).map((parent) => (
+            <FilterListItem
+              key={parent.id}
+              label={parent.name}
+              value={{ parent: parent.id }}
+            />
+          ))}
+        </FilterList>
+        <FilterList label="Geography Type" icon={<MapIcon />}>
+          {['Polygon', 'MultiPolygon'].map((geo_type) => (
+            <FilterListItem
+              key={geo_type}
+              label={geo_type}
+              value={{ geo_type }}
+            />
+          ))}
+        </FilterList>
+        <FilterList label="Mode" icon={<AutoModeIcon />}>
+          {[
+            ...(scannerType === 'rdm' ? RDM_FENCES : UNOWN_FENCES),
+            'unset',
+          ].map((mode) => (
+            <FilterListItem key={mode} label={mode} value={{ mode }} />
+          ))}
+        </FilterList>
+      </CardContent>
+    </Card>
+  )
+}
diff --git a/client/src/pages/admin/geofence/GeofenceList.tsx b/client/src/pages/admin/geofence/GeofenceList.tsx
index dec81f5e..fa0b2364 100644
--- a/client/src/pages/admin/geofence/GeofenceList.tsx
+++ b/client/src/pages/admin/geofence/GeofenceList.tsx
@@ -9,12 +9,11 @@ import {
   TextField,
   TopToolbar,
   CreateButton,
-  SearchInput,
   ReferenceField,
 } from 'react-admin'
 
 import { ExportPolygon } from '@components/dialogs/Polygon'
-
+import { GeofenceFilter } from './GeofenceFilter'
 import { BulkAssignButton } from '../actions/AssignProjectFence'
 import { BulkExportButton, ExportButton } from '../actions/Export'
 import { BulkPushToProd, PushToProd } from '../actions/PushToApi'
@@ -45,7 +44,7 @@ export default function GeofenceList() {
   return (
     <>
       <List
-        filters={[<SearchInput source="q" alwaysOn />]}
+        aside={<GeofenceFilter />}
         pagination={<Pagination rowsPerPageOptions={[25, 50, 100]} />}
         title="Geofences"
         perPage={25}
diff --git a/server/api/src/lib.rs b/server/api/src/lib.rs
index ca089954..286424fd 100644
--- a/server/api/src/lib.rs
+++ b/server/api/src/lib.rs
@@ -100,6 +100,7 @@ pub async fn start() -> io::Result<()> {
                     .service(
                         web::scope("/admin")
                             .service(private::admin::paginate)
+                            .service(private::admin::parent_list)
                             .service(private::admin::get_all)
                             .service(private::admin::search)
                             .service(private::admin::assign)
diff --git a/server/api/src/private/admin.rs b/server/api/src/private/admin.rs
index e97470a0..0cff9bf9 100644
--- a/server/api/src/private/admin.rs
+++ b/server/api/src/private/admin.rs
@@ -1,75 +1,31 @@
 use super::*;
 
-use model::error::ModelError;
+use model::{api::args::AdminReq, error::ModelError};
 use serde::Deserialize;
 use serde_json::json;
 
 use crate::model::{api::args::Response, db, KojiDb};
 
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct AdminReq {
-    pub page: Option<u64>,
-    pub per_page: Option<u64>,
-    pub sort_by: Option<String>,
-    pub order: Option<String>,
-    pub q: Option<String>,
-}
-
 #[derive(Debug, Deserialize)]
 pub struct Search {
     pub query: String,
 }
 
-pub struct AdminReqParsed {
-    pub page: u64,
-    pub per_page: u64,
-    pub sort_by: String,
-    pub order: String,
-    pub q: String,
-}
-
-impl AdminReq {
-    fn parse(self) -> AdminReqParsed {
-        AdminReqParsed {
-            page: self.page.unwrap_or(0),
-            order: self.order.unwrap_or("ASC".to_string()),
-            per_page: self.per_page.unwrap_or(25),
-            sort_by: self.sort_by.unwrap_or("id".to_string()),
-            q: self.q.unwrap_or("".to_string()),
-        }
-    }
-}
-
 #[get("/{resource}/")]
 async fn paginate(
     db: web::Data<KojiDb>,
     query: web::Query<AdminReq>,
     path: actix_web::web::Path<String>,
 ) -> Result<HttpResponse, Error> {
-    let AdminReqParsed {
-        page,
-        per_page,
-        sort_by,
-        order,
-        q,
-    } = query.into_inner().parse();
+    let parsed = query.into_inner().parse();
     let resource = path.into_inner();
 
     let paginated_results = match resource.to_lowercase().as_str() {
-        "geofence" => {
-            db::geofence::Query::paginate(&db.koji_db, page, per_page, order, sort_by, q).await
-        }
-        "project" => {
-            db::project::Query::paginate(&db.koji_db, page, per_page, order, sort_by, q).await
-        }
-        "property" => {
-            db::property::Query::paginate(&db.koji_db, page, per_page, order, sort_by, q).await
-        }
-        "route" => db::route::Query::paginate(&db.koji_db, page, per_page, order, sort_by, q).await,
-        "tileserver" => {
-            db::tile_server::Query::paginate(&db.koji_db, page, per_page, order, sort_by, q).await
-        }
+        "geofence" => db::geofence::Query::paginate(&db.koji_db, parsed).await,
+        "project" => db::project::Query::paginate(&db.koji_db, parsed).await,
+        "property" => db::property::Query::paginate(&db.koji_db, parsed).await,
+        "route" => db::route::Query::paginate(&db.koji_db, parsed).await,
+        "tileserver" => db::tile_server::Query::paginate(&db.koji_db, parsed).await,
         _ => Err(DbErr::Custom("Invalid Resource".to_string())),
     }
     .map_err(actix_web::error::ErrorInternalServerError)?;
@@ -83,6 +39,21 @@ async fn paginate(
     }))
 }
 
+#[get("/geofence/parent")]
+async fn parent_list(db: web::Data<KojiDb>) -> Result<HttpResponse, Error> {
+    let results = db::geofence::Query::unique_parents(&db.koji_db)
+        .await
+        .map_err(actix_web::error::ErrorInternalServerError)?;
+
+    Ok(HttpResponse::Ok().json(Response {
+        data: Some(json!(results)),
+        message: "Success".to_string(),
+        status: "ok".to_string(),
+        stats: None,
+        status_code: 200,
+    }))
+}
+
 #[get("/{resource}/all/")]
 async fn get_all(
     db: web::Data<KojiDb>,
diff --git a/server/model/src/api/args.rs b/server/model/src/api/args.rs
index 028de313..9940aeb6 100644
--- a/server/model/src/api/args.rs
+++ b/server/model/src/api/args.rs
@@ -589,3 +589,50 @@ impl Response {
         }
     }
 }
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AdminReq {
+    pub page: Option<u64>,
+    pub per_page: Option<u64>,
+    pub sort_by: Option<String>,
+    pub order: Option<String>,
+    pub search: Option<String>,
+    pub geo_type: Option<String>,
+    pub project: Option<u32>,
+    pub mode: Option<String>,
+    pub parent: Option<u32>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Search {
+    pub query: String,
+}
+
+impl AdminReq {
+    pub fn parse(self) -> AdminReqParsed {
+        AdminReqParsed {
+            page: self.page.unwrap_or(0),
+            order: self.order.unwrap_or("ASC".to_string()),
+            per_page: self.per_page.unwrap_or(25),
+            sort_by: self.sort_by.unwrap_or("id".to_string()),
+            q: self.search.unwrap_or("".to_string()),
+            geo_type: self.geo_type,
+            project: self.project,
+            mode: self.mode,
+            parent: self.parent,
+        }
+    }
+}
+
+pub struct AdminReqParsed {
+    pub page: u64,
+    pub per_page: u64,
+    pub sort_by: String,
+    pub order: String,
+    pub q: String,
+    pub geo_type: Option<String>,
+    pub project: Option<u32>,
+    pub mode: Option<String>,
+    pub parent: Option<u32>,
+}
diff --git a/server/model/src/db/geofence.rs b/server/model/src/db/geofence.rs
index 15378078..c3ffec11 100644
--- a/server/model/src/db/geofence.rs
+++ b/server/model/src/db/geofence.rs
@@ -4,7 +4,7 @@ use std::{collections::HashMap, str::FromStr};
 
 use crate::{
     api::{
-        args::{ApiQueryArgs, UnknownId},
+        args::{AdminReqParsed, ApiQueryArgs, UnknownId},
         collection::Default,
         GeoFormats, ToCollection,
     },
@@ -89,6 +89,11 @@ pub struct GeofenceNoGeometry {
     // pub updated_at: DateTimeUtc,
 }
 
+#[derive(Serialize, Deserialize, FromQueryResult)]
+pub struct OnlyParent {
+    pub parent: Option<u32>,
+}
+
 impl GeofenceNoGeometry {
     fn to_json(self) -> Json {
         json!(self)
@@ -380,21 +385,34 @@ impl Query {
     /// Returns paginated Geofence models
     pub async fn paginate(
         db: &DatabaseConnection,
-        page: u64,
-        posts_per_page: u64,
-        order: String,
-        sort_by: String,
-        q: String,
+        args: AdminReqParsed,
     ) -> Result<PaginateResults<Vec<Json>>, DbErr> {
-        let column = Column::from_str(&sort_by).unwrap_or(Column::Name);
+        let column = Column::from_str(&args.sort_by).unwrap_or(Column::Name);
+
+        let mut paginator = Entity::find()
+            .order_by(column, parse_order(&args.order))
+            .filter(Column::Name.like(format!("%{}%", args.q).as_str()));
+
+        if let Some(parent) = args.parent {
+            paginator = paginator.filter(Column::Parent.eq(parent));
+        }
+        if let Some(geo_type) = args.geo_type {
+            paginator = paginator.filter(Column::GeoType.eq(geo_type));
+        }
+        if let Some(mode) = args.mode {
+            paginator = paginator.filter(Column::Mode.eq(mode));
+        }
+        if let Some(project_id) = args.project {
+            paginator = paginator
+                .inner_join(project::Entity)
+                .filter(project::Column::Id.eq(project_id));
+        }
+
+        let paginator = paginator.paginate(db, args.per_page);
 
-        let paginator = Entity::find()
-            .order_by(column, parse_order(&order))
-            .filter(Column::Name.like(format!("%{}%", q).as_str()))
-            .paginate(db, posts_per_page);
         let total = paginator.num_items_and_pages().await?;
 
-        let results = paginator.fetch_page(page).await?;
+        let results = paginator.fetch_page(args.page).await?;
 
         let projects = future::try_join_all(
             results
@@ -436,15 +454,19 @@ impl Query {
             })
             .collect();
 
-        if sort_by.contains("length") {
-            json_related_sort(&mut results, &sort_by.replace(".length", ""), order);
+        if args.sort_by.contains("length") {
+            json_related_sort(
+                &mut results,
+                &args.sort_by.replace(".length", ""),
+                args.order,
+            );
         }
 
         Ok(PaginateResults {
             results,
             total: total.number_of_items,
-            has_prev: total.number_of_pages == page + 1,
-            has_next: page + 1 < total.number_of_pages,
+            has_prev: total.number_of_pages == args.page + 1,
+            has_next: args.page + 1 < total.number_of_pages,
         })
     }
 
@@ -833,4 +855,31 @@ impl Query {
 
         Ok(items.to_collection(None, None))
     }
+
+    pub async fn unique_parents(db: &DatabaseConnection) -> Result<Vec<Json>, ModelError> {
+        let items = Entity::find()
+            .filter(Column::Parent.is_not_null())
+            .select_only()
+            .column(Column::Parent)
+            .distinct()
+            .into_model::<OnlyParent>()
+            .all(db)
+            .await?;
+        let items = Entity::find()
+            .filter(Column::Id.is_in(items.into_iter().filter_map(|item| {
+                if let Some(parent) = item.parent {
+                    Some(parent)
+                } else {
+                    None
+                }
+            })))
+            .select_only()
+            .column(Column::Id)
+            .column(Column::Name)
+            .distinct()
+            .into_json()
+            .all(db)
+            .await?;
+        Ok(items)
+    }
 }
diff --git a/server/model/src/db/project.rs b/server/model/src/db/project.rs
index 27d1e253..8171b896 100644
--- a/server/model/src/db/project.rs
+++ b/server/model/src/db/project.rs
@@ -1,6 +1,9 @@
 //! SeaORM Entity. Generated by sea-orm-codegen 0.10.1
 
-use crate::utils::{json::JsonToModel, json_related_sort, parse_order};
+use crate::{
+    api::args::AdminReqParsed,
+    utils::{json::JsonToModel, json_related_sort, parse_order},
+};
 
 use super::*;
 use sea_orm::entity::prelude::*;
@@ -95,22 +98,18 @@ impl Query {
 
     pub async fn paginate(
         db: &DatabaseConnection,
-        page: u64,
-        posts_per_page: u64,
-        order: String,
-        sort_by: String,
-        q: String,
+        args: AdminReqParsed,
     ) -> Result<PaginateResults<Vec<Json>>, DbErr> {
         let paginator = project::Entity::find()
             .order_by(
-                Column::from_str(&sort_by).unwrap_or(Column::Name),
-                parse_order(&order),
+                Column::from_str(&args.sort_by).unwrap_or(Column::Name),
+                parse_order(&args.order),
             )
-            .filter(Column::Name.like(format!("%{}%", q).as_str()))
-            .paginate(db, posts_per_page);
+            .filter(Column::Name.like(format!("%{}%", args.q).as_str()))
+            .paginate(db, args.per_page);
         let total = paginator.num_items_and_pages().await?;
 
-        let results: Vec<Model> = match paginator.fetch_page(page).await {
+        let results: Vec<Model> = match paginator.fetch_page(args.page).await {
             Ok(results) => results,
             Err(err) => {
                 println!("[project] Error paginating, {:?}", err);
@@ -142,15 +141,15 @@ impl Query {
             })
             .collect();
 
-        if sort_by == "geofences" {
-            json_related_sort(&mut results, &sort_by, order);
+        if args.sort_by == "geofences" {
+            json_related_sort(&mut results, &args.sort_by, args.order);
         }
 
         Ok(PaginateResults {
             results,
             total: total.number_of_items,
-            has_prev: total.number_of_pages == page + 1,
-            has_next: page + 1 < total.number_of_pages,
+            has_prev: total.number_of_pages == args.page + 1,
+            has_next: args.page + 1 < total.number_of_pages,
         })
     }
 
diff --git a/server/model/src/db/property.rs b/server/model/src/db/property.rs
index d7e2d685..f9f73411 100644
--- a/server/model/src/db/property.rs
+++ b/server/model/src/db/property.rs
@@ -1,8 +1,11 @@
 //! SeaORM Entity. Generated by sea-orm-codegen 0.10.1
 
-use crate::utils::{
-    json::{parse_property_value, JsonToModel},
-    parse_order,
+use crate::{
+    api::args::AdminReqParsed,
+    utils::{
+        json::{parse_property_value, JsonToModel},
+        parse_order,
+    },
 };
 
 use super::{sea_orm_active_enums::Category, *};
@@ -42,21 +45,17 @@ pub struct Query;
 impl Query {
     pub async fn paginate(
         db: &DatabaseConnection,
-        page: u64,
-        posts_per_page: u64,
-        order: String,
-        sort_by: String,
-        q: String,
+        args: AdminReqParsed,
     ) -> Result<PaginateResults<Vec<Json>>, DbErr> {
-        let column = Column::from_str(&sort_by).unwrap_or(Column::Name);
+        let column = Column::from_str(&args.sort_by).unwrap_or(Column::Name);
 
         let paginator = property::Entity::find()
-            .order_by(column, parse_order(&order))
-            .filter(Column::Name.like(format!("%{}%", q).as_str()))
-            .paginate(db, posts_per_page);
+            .order_by(column, parse_order(&args.order))
+            .filter(Column::Name.like(format!("%{}%", args.q).as_str()))
+            .paginate(db, args.per_page);
         let total = paginator.num_items_and_pages().await?;
 
-        let results: Vec<Model> = paginator.fetch_page(page).await?;
+        let results: Vec<Model> = paginator.fetch_page(args.page).await?;
 
         let geofences = future::try_join_all(
             results
@@ -88,8 +87,8 @@ impl Query {
         Ok(PaginateResults {
             results,
             total: total.number_of_items,
-            has_prev: total.number_of_pages == page + 1,
-            has_next: page + 1 < total.number_of_pages,
+            has_prev: total.number_of_pages == args.page + 1,
+            has_next: args.page + 1 < total.number_of_pages,
         })
     }
 
diff --git a/server/model/src/db/route.rs b/server/model/src/db/route.rs
index ba6e3dee..3ea03ab0 100644
--- a/server/model/src/db/route.rs
+++ b/server/model/src/db/route.rs
@@ -10,7 +10,7 @@ use std::collections::HashMap;
 use std::str::FromStr;
 
 use crate::{
-    api::{GeoFormats, ToCollection, ToFeature},
+    api::{args::AdminReqParsed, GeoFormats, ToCollection, ToFeature},
     db::sea_orm_active_enums::Type,
     error::ModelError,
     utils::{get_enum, json::JsonToModel, parse_order},
@@ -101,22 +101,18 @@ pub struct Query;
 impl Query {
     pub async fn paginate(
         db: &DatabaseConnection,
-        page: u64,
-        posts_per_page: u64,
-        order: String,
-        sort_by: String,
-        q: String,
+        args: AdminReqParsed,
     ) -> Result<PaginateResults<Vec<Json>>, DbErr> {
-        let column = Column::from_str(&sort_by).unwrap_or(Column::Name);
+        let column = Column::from_str(&args.sort_by).unwrap_or(Column::Name);
 
         let paginator = Entity::find()
-            .order_by(column, parse_order(&order))
-            .filter(Column::Name.like(format!("%{}%", q).as_str()))
-            .paginate(db, posts_per_page);
+            .order_by(column, parse_order(&args.order))
+            .filter(Column::Name.like(format!("%{}%", args.q).as_str()))
+            .paginate(db, args.per_page);
         let total = paginator.num_items_and_pages().await?;
 
         let results: Vec<Json> = paginator
-            .fetch_page(page)
+            .fetch_page(args.page)
             .await?
             .into_iter()
             .map(|model| {
@@ -143,8 +139,8 @@ impl Query {
         Ok(PaginateResults {
             results,
             total: total.number_of_items,
-            has_prev: total.number_of_pages == page + 1,
-            has_next: page + 1 < total.number_of_pages,
+            has_prev: total.number_of_pages == args.page + 1,
+            has_next: args.page + 1 < total.number_of_pages,
         })
     }
 
diff --git a/server/model/src/db/tile_server.rs b/server/model/src/db/tile_server.rs
index 4404dac6..2cf3319f 100644
--- a/server/model/src/db/tile_server.rs
+++ b/server/model/src/db/tile_server.rs
@@ -5,6 +5,7 @@ use serde_json::json;
 use std::str::FromStr;
 
 use crate::{
+    api::args::AdminReqParsed,
     error::ModelError,
     utils::{json::JsonToModel, parse_order},
 };
@@ -49,22 +50,18 @@ impl Query {
 
     pub async fn paginate(
         db: &DatabaseConnection,
-        page: u64,
-        posts_per_page: u64,
-        order: String,
-        sort_by: String,
-        q: String,
+        args: AdminReqParsed,
     ) -> Result<PaginateResults<Vec<Json>>, DbErr> {
         let paginator = Entity::find()
             .order_by(
-                Column::from_str(&sort_by).unwrap_or(Column::Name),
-                parse_order(&order),
+                Column::from_str(&args.sort_by).unwrap_or(Column::Name),
+                parse_order(&args.order),
             )
-            .filter(Column::Name.like(format!("%{}%", q).as_str()))
-            .paginate(db, posts_per_page);
+            .filter(Column::Name.like(format!("%{}%", args.q).as_str()))
+            .paginate(db, args.per_page);
         let total = paginator.num_items_and_pages().await?;
 
-        let results: Vec<Model> = match paginator.fetch_page(page).await {
+        let results: Vec<Model> = match paginator.fetch_page(args.page).await {
             Ok(results) => results,
             Err(err) => {
                 log::error!("[project] Error paginating, {:?}", err);
@@ -77,8 +74,8 @@ impl Query {
         Ok(PaginateResults {
             results,
             total: total.number_of_items,
-            has_prev: total.number_of_pages == page + 1,
-            has_next: page + 1 < total.number_of_pages,
+            has_prev: total.number_of_pages == args.page + 1,
+            has_next: args.page + 1 < total.number_of_pages,
         })
     }
 

From 3a956832b490dabe8151ab59634c7832667dcd5c Mon Sep 17 00:00:00 2001
From: Derick M <58572875+TurtIeSocks@users.noreply.github.com>
Date: Sun, 23 Apr 2023 22:12:00 -0400
Subject: [PATCH 2/2] route filtering

- add the same kind of arguments to the route resource
- move some buttons to a drop down menu as the list was getting crowded
-
---
 client/src/pages/admin/actions/Extras.tsx     | 43 +++++++++++++++
 client/src/pages/admin/actions/PushToApi.tsx  | 12 ++++-
 .../pages/admin/geofence/GeofenceFilter.tsx   | 10 ++--
 .../src/pages/admin/geofence/GeofenceList.tsx |  7 ++-
 client/src/pages/admin/route/RouteFilter.tsx  | 53 +++++++++++++++++++
 client/src/pages/admin/route/RouteList.tsx    | 18 ++++---
 server/api/src/private/admin.rs               | 18 +++++--
 server/model/src/api/args.rs                  |  9 ++--
 server/model/src/db/geofence.rs               |  3 +-
 server/model/src/db/route.rs                  | 39 ++++++++++++--
 10 files changed, 179 insertions(+), 33 deletions(-)
 create mode 100644 client/src/pages/admin/actions/Extras.tsx
 create mode 100644 client/src/pages/admin/route/RouteFilter.tsx

diff --git a/client/src/pages/admin/actions/Extras.tsx b/client/src/pages/admin/actions/Extras.tsx
new file mode 100644
index 00000000..71931051
--- /dev/null
+++ b/client/src/pages/admin/actions/Extras.tsx
@@ -0,0 +1,43 @@
+import * as React from 'react'
+import { DeleteWithUndoButton } from 'react-admin'
+import { IconButton, Menu, MenuItem } from '@mui/material'
+import MoreVertIcon from '@mui/icons-material/MoreVert'
+
+import { ExportButton } from './Export'
+import { PushToProd } from './PushToApi'
+
+export function ExtraMenuActions({ resource }: { resource: string }) {
+  const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null)
+
+  const handleClose = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    setAnchorEl(null)
+  }
+
+  return (
+    <>
+      <IconButton
+        onClick={(e) => {
+          e.stopPropagation()
+          setAnchorEl(e.currentTarget)
+        }}
+      >
+        <MoreVertIcon />
+      </IconButton>
+      <Menu open={!!anchorEl} anchorEl={anchorEl} onClose={handleClose}>
+        <MenuItem onClick={handleClose}>
+          <DeleteWithUndoButton />
+        </MenuItem>
+        <MenuItem onClick={handleClose}>
+          <ExportButton resource={resource} />
+        </MenuItem>
+        <MenuItem
+          onClick={handleClose}
+          sx={{ display: { xs: 'flex', sm: 'none' } }}
+        >
+          <PushToProd resource={resource} />
+        </MenuItem>
+      </Menu>
+    </>
+  )
+}
diff --git a/client/src/pages/admin/actions/PushToApi.tsx b/client/src/pages/admin/actions/PushToApi.tsx
index ef04034c..536d1b58 100644
--- a/client/src/pages/admin/actions/PushToApi.tsx
+++ b/client/src/pages/admin/actions/PushToApi.tsx
@@ -11,16 +11,18 @@ import {
 import { useMutation } from 'react-query'
 
 import type { BasicKojiEntry } from '@assets/types'
-import { capitalize } from '@mui/material'
+import { SxProps, capitalize } from '@mui/material'
 import { fetchWrapper } from '@services/fetches'
 
 export function BaseButton({
   onClick,
+  sx,
 }: {
   onClick: React.MouseEventHandler<HTMLButtonElement> | undefined
+  sx?: SxProps
 }) {
   return (
-    <Button label="Sync" size="small" onClick={onClick}>
+    <Button label="Sync" size="small" onClick={onClick} sx={sx}>
       <SyncIcon />
     </Button>
   )
@@ -28,8 +30,10 @@ export function BaseButton({
 
 export function PushToProd<T extends BasicKojiEntry>({
   resource,
+  sx,
 }: {
   resource: string
+  sx?: SxProps
 }) {
   const record = useRecordContext<T>()
   const notify = useNotify()
@@ -52,6 +56,7 @@ export function PushToProd<T extends BasicKojiEntry>({
 
   return (
     <BaseButton
+      sx={sx}
       onClick={(event) => {
         event.stopPropagation()
         sync.mutate()
@@ -62,8 +67,10 @@ export function PushToProd<T extends BasicKojiEntry>({
 
 export function BulkPushToProd<T extends BasicKojiEntry>({
   resource,
+  sx,
 }: {
   resource: string
+  sx?: SxProps
 }) {
   const { selectedIds } = useListContext<T>()
   const unselectAll = useUnselectAll(resource)
@@ -95,6 +102,7 @@ export function BulkPushToProd<T extends BasicKojiEntry>({
 
   return (
     <BaseButton
+      sx={sx}
       onClick={(event) => {
         event.stopPropagation()
         unselectAll()
diff --git a/client/src/pages/admin/geofence/GeofenceFilter.tsx b/client/src/pages/admin/geofence/GeofenceFilter.tsx
index 7ea1a7f2..052f0392 100644
--- a/client/src/pages/admin/geofence/GeofenceFilter.tsx
+++ b/client/src/pages/admin/geofence/GeofenceFilter.tsx
@@ -28,7 +28,7 @@ export function GeofenceFilter() {
     ),
   )
   return (
-    <Card sx={{ order: -1, mt: 9, width: 200 }}>
+    <Card sx={{ order: -1, width: 200 }}>
       <CardContent>
         {/* <SavedQueriesList /> */}
         <FilterLiveSearch />
@@ -51,12 +51,8 @@ export function GeofenceFilter() {
           ))}
         </FilterList>
         <FilterList label="Geography Type" icon={<MapIcon />}>
-          {['Polygon', 'MultiPolygon'].map((geo_type) => (
-            <FilterListItem
-              key={geo_type}
-              label={geo_type}
-              value={{ geo_type }}
-            />
+          {['Polygon', 'MultiPolygon'].map((geotype) => (
+            <FilterListItem key={geotype} label={geotype} value={{ geotype }} />
           ))}
         </FilterList>
         <FilterList label="Mode" icon={<AutoModeIcon />}>
diff --git a/client/src/pages/admin/geofence/GeofenceList.tsx b/client/src/pages/admin/geofence/GeofenceList.tsx
index fa0b2364..94ae9e70 100644
--- a/client/src/pages/admin/geofence/GeofenceList.tsx
+++ b/client/src/pages/admin/geofence/GeofenceList.tsx
@@ -2,7 +2,6 @@ import * as React from 'react'
 import {
   BulkDeleteWithUndoButton,
   Datagrid,
-  DeleteWithUndoButton,
   EditButton,
   List,
   Pagination,
@@ -15,10 +14,11 @@ import {
 import { ExportPolygon } from '@components/dialogs/Polygon'
 import { GeofenceFilter } from './GeofenceFilter'
 import { BulkAssignButton } from '../actions/AssignProjectFence'
-import { BulkExportButton, ExportButton } from '../actions/Export'
+import { BulkExportButton } from '../actions/Export'
 import { BulkPushToProd, PushToProd } from '../actions/PushToApi'
 import { GeofenceExpand } from './GeofenceExpand'
 import { BulkAssignFenceButton } from '../actions/AssignParentFence'
+import { ExtraMenuActions } from '../actions/Extras'
 
 function ListActions() {
   return (
@@ -61,9 +61,8 @@ export default function GeofenceList() {
           <TextField source="mode" />
           <TextField source="geo_type" />
           <EditButton />
-          <DeleteWithUndoButton />
           <PushToProd resource="geofence" />
-          <ExportButton resource="geofence" />
+          <ExtraMenuActions resource="geofence" />
         </Datagrid>
       </List>
       <ExportPolygon />
diff --git a/client/src/pages/admin/route/RouteFilter.tsx b/client/src/pages/admin/route/RouteFilter.tsx
new file mode 100644
index 00000000..91014d97
--- /dev/null
+++ b/client/src/pages/admin/route/RouteFilter.tsx
@@ -0,0 +1,53 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import * as React from 'react'
+import {
+  // SavedQueriesList,
+  FilterLiveSearch,
+  FilterList,
+  FilterListItem,
+} from 'react-admin'
+import { useQuery } from 'react-query'
+import { Card, CardContent } from '@mui/material'
+import AutoModeIcon from '@mui/icons-material/AutoMode'
+import SupervisedUserCircleIcon from '@mui/icons-material/SupervisedUserCircle'
+
+import { useStatic } from '@hooks/useStatic'
+import { RDM_ROUTES, UNOWN_ROUTES } from '@assets/constants'
+import { BasicKojiEntry, KojiResponse } from '@assets/types'
+import { fetchWrapper } from '@services/fetches'
+
+export function RouteFilter() {
+  const { scannerType } = useStatic.getState()
+  const { data } = useQuery('unique_geofences', () =>
+    fetchWrapper<KojiResponse<BasicKojiEntry[]>>(
+      '/internal/admin/route/parent',
+    ),
+  )
+  return (
+    <Card sx={{ order: -1, width: 225 }}>
+      <CardContent>
+        {/* <SavedQueriesList /> */}
+        <FilterLiveSearch />
+        <FilterList label="Mode" icon={<AutoModeIcon />}>
+          {[
+            ...(scannerType === 'rdm' ? RDM_ROUTES : UNOWN_ROUTES),
+            'unset',
+          ].map((mode) => (
+            <FilterListItem key={mode} label={mode} value={{ mode }} />
+          ))}
+        </FilterList>
+        <FilterList label="Geofence" icon={<SupervisedUserCircleIcon />}>
+          <div style={{ maxHeight: 400, overflow: 'auto' }}>
+            {(data?.data || []).map((fence) => (
+              <FilterListItem
+                key={fence.id}
+                label={fence.name}
+                value={{ geofenceid: fence.id }}
+              />
+            ))}
+          </div>
+        </FilterList>
+      </CardContent>
+    </Card>
+  )
+}
diff --git a/client/src/pages/admin/route/RouteList.tsx b/client/src/pages/admin/route/RouteList.tsx
index 55bc98ae..9a335c53 100644
--- a/client/src/pages/admin/route/RouteList.tsx
+++ b/client/src/pages/admin/route/RouteList.tsx
@@ -2,7 +2,6 @@ import * as React from 'react'
 import {
   BulkDeleteWithUndoButton,
   Datagrid,
-  DeleteWithUndoButton,
   EditButton,
   List,
   NumberField,
@@ -11,12 +10,13 @@ import {
   TopToolbar,
   CreateButton,
   ReferenceField,
-  SearchInput,
 } from 'react-admin'
 import { ExportPolygon } from '@components/dialogs/Polygon'
 
-import { BulkExportButton, ExportButton } from '../actions/Export'
+import { BulkExportButton } from '../actions/Export'
 import { BulkPushToProd, PushToProd } from '../actions/PushToApi'
+import { RouteFilter } from './RouteFilter'
+import { ExtraMenuActions } from '../actions/Extras'
 
 function ListActions() {
   return (
@@ -29,8 +29,8 @@ function ListActions() {
 function BulkActions() {
   return (
     <>
-      <BulkDeleteWithUndoButton resource="route" />
       <BulkPushToProd resource="route" />
+      <BulkDeleteWithUndoButton resource="route" size="small" />
       <BulkExportButton resource="route" />
     </>
   )
@@ -40,7 +40,7 @@ export default function RouteList() {
   return (
     <>
       <List
-        filters={[<SearchInput source="q" alwaysOn />]}
+        aside={<RouteFilter />}
         pagination={<Pagination rowsPerPageOptions={[25, 50, 100]} />}
         title="Routes"
         perPage={25}
@@ -54,9 +54,11 @@ export default function RouteList() {
           <ReferenceField source="geofence_id" reference="geofence" />
           <NumberField source="hops" label="Hops" sortable={false} />
           <EditButton />
-          <DeleteWithUndoButton />
-          <PushToProd resource="route" />
-          <ExportButton resource="route" />
+          <PushToProd
+            resource="route"
+            sx={{ display: { xs: 'none', sm: 'flex' } }}
+          />
+          <ExtraMenuActions resource="route" />
         </Datagrid>
       </List>
       <ExportPolygon />
diff --git a/server/api/src/private/admin.rs b/server/api/src/private/admin.rs
index 0cff9bf9..6dddaf3c 100644
--- a/server/api/src/private/admin.rs
+++ b/server/api/src/private/admin.rs
@@ -39,11 +39,19 @@ async fn paginate(
     }))
 }
 
-#[get("/geofence/parent")]
-async fn parent_list(db: web::Data<KojiDb>) -> Result<HttpResponse, Error> {
-    let results = db::geofence::Query::unique_parents(&db.koji_db)
-        .await
-        .map_err(actix_web::error::ErrorInternalServerError)?;
+#[get("/{resource}/parent")]
+async fn parent_list(
+    db: web::Data<KojiDb>,
+    path: actix_web::web::Path<String>,
+) -> Result<HttpResponse, Error> {
+    let resource = path.into_inner();
+
+    let results = match resource.to_lowercase().as_str() {
+        "geofence" => db::geofence::Query::unique_parents(&db.koji_db).await,
+        "route" => db::route::Query::unique_geofence(&db.koji_db).await,
+        _ => Err(ModelError::Custom("Invalid Resource".to_string())),
+    }
+    .map_err(actix_web::error::ErrorInternalServerError)?;
 
     Ok(HttpResponse::Ok().json(Response {
         data: Some(json!(results)),
diff --git a/server/model/src/api/args.rs b/server/model/src/api/args.rs
index 9940aeb6..afbbfbcd 100644
--- a/server/model/src/api/args.rs
+++ b/server/model/src/api/args.rs
@@ -598,10 +598,11 @@ pub struct AdminReq {
     pub sort_by: Option<String>,
     pub order: Option<String>,
     pub search: Option<String>,
-    pub geo_type: Option<String>,
+    pub geotype: Option<String>,
     pub project: Option<u32>,
     pub mode: Option<String>,
     pub parent: Option<u32>,
+    pub geofenceid: Option<u32>,
 }
 
 #[derive(Debug, Deserialize)]
@@ -617,10 +618,11 @@ impl AdminReq {
             per_page: self.per_page.unwrap_or(25),
             sort_by: self.sort_by.unwrap_or("id".to_string()),
             q: self.search.unwrap_or("".to_string()),
-            geo_type: self.geo_type,
+            geotype: self.geotype,
             project: self.project,
             mode: self.mode,
             parent: self.parent,
+            geofenceid: self.geofenceid,
         }
     }
 }
@@ -631,8 +633,9 @@ pub struct AdminReqParsed {
     pub sort_by: String,
     pub order: String,
     pub q: String,
-    pub geo_type: Option<String>,
+    pub geotype: Option<String>,
     pub project: Option<u32>,
     pub mode: Option<String>,
     pub parent: Option<u32>,
+    pub geofenceid: Option<u32>,
 }
diff --git a/server/model/src/db/geofence.rs b/server/model/src/db/geofence.rs
index c3ffec11..977504ca 100644
--- a/server/model/src/db/geofence.rs
+++ b/server/model/src/db/geofence.rs
@@ -396,7 +396,8 @@ impl Query {
         if let Some(parent) = args.parent {
             paginator = paginator.filter(Column::Parent.eq(parent));
         }
-        if let Some(geo_type) = args.geo_type {
+
+        if let Some(geo_type) = args.geotype {
             paginator = paginator.filter(Column::GeoType.eq(geo_type));
         }
         if let Some(mode) = args.mode {
diff --git a/server/model/src/db/route.rs b/server/model/src/db/route.rs
index 3ea03ab0..6920863a 100644
--- a/server/model/src/db/route.rs
+++ b/server/model/src/db/route.rs
@@ -61,6 +61,11 @@ pub struct RouteNoGeometry {
     pub updated_at: DateTimeUtc,
 }
 
+#[derive(Serialize, Deserialize, FromQueryResult)]
+pub struct OnlyGeofenceId {
+    pub geofence_id: u32,
+}
+
 impl ToFeatureFromModel for Model {
     fn to_feature(self, internal: bool) -> Result<Feature, ModelError> {
         let Self {
@@ -105,10 +110,18 @@ impl Query {
     ) -> Result<PaginateResults<Vec<Json>>, DbErr> {
         let column = Column::from_str(&args.sort_by).unwrap_or(Column::Name);
 
-        let paginator = Entity::find()
+        let mut paginator = Entity::find()
             .order_by(column, parse_order(&args.order))
-            .filter(Column::Name.like(format!("%{}%", args.q).as_str()))
-            .paginate(db, args.per_page);
+            .filter(Column::Name.like(format!("%{}%", args.q).as_str()));
+
+        if let Some(geofence_id) = args.geofenceid {
+            paginator = paginator.filter(Column::GeofenceId.eq(geofence_id));
+        }
+        if let Some(mode) = args.mode {
+            paginator = paginator.filter(Column::Mode.eq(mode));
+        }
+
+        let paginator = paginator.paginate(db, args.per_page);
         let total = paginator.num_items_and_pages().await?;
 
         let results: Vec<Json> = paginator
@@ -540,4 +553,24 @@ impl Query {
             .all(db)
             .await?)
     }
+
+    pub async fn unique_geofence(db: &DatabaseConnection) -> Result<Vec<Json>, ModelError> {
+        let items = Entity::find()
+            .select_only()
+            .column(Column::GeofenceId)
+            .distinct()
+            .into_model::<OnlyGeofenceId>()
+            .all(db)
+            .await?;
+        let items = geofence::Entity::find()
+            .filter(geofence::Column::Id.is_in(items.into_iter().map(|item| item.geofence_id)))
+            .select_only()
+            .column(geofence::Column::Id)
+            .column(geofence::Column::Name)
+            .distinct()
+            .into_json()
+            .all(db)
+            .await?;
+        Ok(items)
+    }
 }