mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Add "go to last page" and page count to notifications and devices lists
The list endpoints now return a total count alongside the page so the
front-end can show how many pages exist and offer a last-page jump.
Backend: add count(is_new) and count_devices(...) (sharing a WHERE-builder
with list_devices so page and count can't drift), wrap both list responses
in {items, total_count} structs, and register them with utoipa.
Front-end: parse the wrapper shape (dropping the fetch-one-extra trick),
add a Last-page button and a responsive "Page X of Y" / "X / Y" label to
the shared PaginationBar, and track the total in both lists. Notifications
re-sync the count on every fetch and decrement it locally on mark-read/
unread removals so the count stays accurate without a re-fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3cb104e364
commit
844d7d189c
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
- [ ] Review the whole codebase for dead code, duplication and simplicity
|
- [ ] Review the whole codebase for dead code, duplication and simplicity
|
||||||
- [x] In the devices list, add an order by type (in the wide version it should be over the icon on the left of the list)
|
- [x] In the devices list, add an order by type (in the wide version it should be over the icon on the left of the list)
|
||||||
- [ ] How expensive it is to get the total number of pages and implement a go to last page
|
- [x] How expensive it is to get the total number of pages and implement a go to last page
|
||||||
- [x] Mobile - Device details - Chart buttons not fully visible, replace with combo only for narrow devices
|
- [x] Mobile - Device details - Chart buttons not fully visible, replace with combo only for narrow devices
|
||||||
|
|
||||||
## Improve engine
|
## Improve engine
|
||||||
|
|||||||
+167
-44
@@ -30,6 +30,53 @@ fn resolve_sort_direction(sort_order: Option<&str>) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Builds the shared `WHERE` clause (and its bound parameters) used by both
|
||||||
|
// `list_devices` and `count_devices`, so the page and its total count always
|
||||||
|
// apply the exact same filters. The returned clause starts with "WHERE 1=1" so
|
||||||
|
// callers can append further SQL (ordering, paging) unconditionally.
|
||||||
|
fn build_device_filters(
|
||||||
|
is_registered: Option<bool>,
|
||||||
|
last_seen_from: Option<DateTime<Utc>>,
|
||||||
|
last_seen_to: Option<DateTime<Utc>>,
|
||||||
|
owner: Option<String>,
|
||||||
|
device_type: Option<String>,
|
||||||
|
vendor: Option<String>,
|
||||||
|
) -> (String, Vec<rusqlite::types::Value>) {
|
||||||
|
let mut clause = "WHERE 1=1 ".to_string();
|
||||||
|
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||||
|
if let Some(is_registered) = is_registered {
|
||||||
|
debug!("Adding filter is_registered={}", is_registered);
|
||||||
|
clause.push_str("AND is_registered=? ");
|
||||||
|
params.push(is_registered.into());
|
||||||
|
};
|
||||||
|
if let Some(last_seen_from) = last_seen_from {
|
||||||
|
debug!("Adding filter last_seen>={}", last_seen_from.to_rfc3339());
|
||||||
|
clause.push_str("AND last_seen>=? ");
|
||||||
|
params.push(last_seen_from.to_rfc3339().into());
|
||||||
|
};
|
||||||
|
if let Some(last_seen_to) = last_seen_to {
|
||||||
|
debug!("Adding filter last_seen<={}", last_seen_to.to_rfc3339());
|
||||||
|
clause.push_str("AND last_seen<=? ");
|
||||||
|
params.push(last_seen_to.to_rfc3339().into());
|
||||||
|
};
|
||||||
|
if let Some(owner) = owner {
|
||||||
|
debug!("Adding filter owner={}", owner);
|
||||||
|
clause.push_str("AND owner LIKE ? ");
|
||||||
|
params.push(format!("%{}%", owner).into());
|
||||||
|
};
|
||||||
|
if let Some(device_type) = device_type {
|
||||||
|
debug!("Adding filter device_type={}", device_type);
|
||||||
|
clause.push_str("AND device_type=? ");
|
||||||
|
params.push(device_type.into());
|
||||||
|
}
|
||||||
|
if let Some(vendor) = vendor {
|
||||||
|
debug!("Adding filter vendor={}", vendor);
|
||||||
|
clause.push_str("AND vendor=? ");
|
||||||
|
params.push(vendor.into());
|
||||||
|
}
|
||||||
|
(clause, params)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn list_devices(
|
pub fn list_devices(
|
||||||
is_registered: Option<bool>,
|
is_registered: Option<bool>,
|
||||||
@@ -47,38 +94,18 @@ pub fn list_devices(
|
|||||||
let conn = db::get_db_connection();
|
let conn = db::get_db_connection();
|
||||||
|
|
||||||
// Prepare SQL and parameters
|
// Prepare SQL and parameters
|
||||||
let mut sql_statement = "SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name FROM devices WHERE 1=1 ".to_string();
|
let (filters, mut params) = build_device_filters(
|
||||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
is_registered,
|
||||||
if let Some(is_registered) = is_registered {
|
last_seen_from,
|
||||||
debug!("Adding filter is_registered={}", is_registered);
|
last_seen_to,
|
||||||
sql_statement.push_str("AND is_registered=? ");
|
owner,
|
||||||
params.push(is_registered.into());
|
device_type,
|
||||||
};
|
vendor,
|
||||||
if let Some(last_seen_from) = last_seen_from {
|
);
|
||||||
debug!("Adding filter last_seen>={}", last_seen_from.to_rfc3339());
|
let mut sql_statement = format!(
|
||||||
sql_statement.push_str("AND last_seen>=? ");
|
"SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name FROM devices {}",
|
||||||
params.push(last_seen_from.to_rfc3339().into());
|
filters
|
||||||
};
|
);
|
||||||
if let Some(last_seen_to) = last_seen_to {
|
|
||||||
debug!("Adding filter last_seen<={}", last_seen_to.to_rfc3339());
|
|
||||||
sql_statement.push_str("AND last_seen<=? ");
|
|
||||||
params.push(last_seen_to.to_rfc3339().into());
|
|
||||||
};
|
|
||||||
if let Some(owner) = owner {
|
|
||||||
debug!("Adding filter owner={}", owner);
|
|
||||||
sql_statement.push_str("AND owner LIKE ? ");
|
|
||||||
params.push(format!("%{}%", owner).into());
|
|
||||||
};
|
|
||||||
if let Some(device_type) = device_type {
|
|
||||||
debug!("Adding filter device_type={}", device_type);
|
|
||||||
sql_statement.push_str("AND device_type=? ");
|
|
||||||
params.push(device_type.into());
|
|
||||||
}
|
|
||||||
if let Some(vendor) = vendor {
|
|
||||||
debug!("Adding filter vendor={}", vendor);
|
|
||||||
sql_statement.push_str("AND vendor=? ");
|
|
||||||
params.push(vendor.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// List order — both column and direction are validated against a whitelist so user input
|
// List order — both column and direction are validated against a whitelist so user input
|
||||||
// is never interpolated raw. A secondary `mac_address ASC` keeps paging deterministic when
|
// is never interpolated raw. A secondary `mac_address ASC` keeps paging deterministic when
|
||||||
@@ -122,6 +149,38 @@ pub fn list_devices(
|
|||||||
Ok(devices)
|
Ok(devices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Counts the devices matching the given filters, ignoring paging. Used to report
|
||||||
|
// the total number of pages alongside a `list_devices` page.
|
||||||
|
pub fn count_devices(
|
||||||
|
is_registered: Option<bool>,
|
||||||
|
last_seen_from: Option<DateTime<Utc>>,
|
||||||
|
last_seen_to: Option<DateTime<Utc>>,
|
||||||
|
owner: Option<String>,
|
||||||
|
device_type: Option<String>,
|
||||||
|
vendor: Option<String>,
|
||||||
|
) -> Result<i64, DbError> {
|
||||||
|
debug!("Counting devices");
|
||||||
|
let conn = db::get_db_connection();
|
||||||
|
|
||||||
|
let (filters, params) = build_device_filters(
|
||||||
|
is_registered,
|
||||||
|
last_seen_from,
|
||||||
|
last_seen_to,
|
||||||
|
owner,
|
||||||
|
device_type,
|
||||||
|
vendor,
|
||||||
|
);
|
||||||
|
let sql_statement = format!("SELECT COUNT(*) FROM devices {}", filters);
|
||||||
|
|
||||||
|
let count: i64 = conn.query_row(
|
||||||
|
sql_statement.as_str(),
|
||||||
|
params_from_iter(params.iter()),
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
// Read device from its MAC address
|
// Read device from its MAC address
|
||||||
pub fn read(mac_address: String) -> Option<Device> {
|
pub fn read(mac_address: String) -> Option<Device> {
|
||||||
let conn = db::get_db_connection();
|
let conn = db::get_db_connection();
|
||||||
@@ -368,7 +427,8 @@ mod tests {
|
|||||||
assert!(devices.len() >= 3, "There should be at least 3 devices");
|
assert!(devices.len() >= 3, "There should be at least 3 devices");
|
||||||
// Validate 1 device data
|
// Validate 1 device data
|
||||||
let device = devices
|
let device = devices
|
||||||
.iter().find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
|
.iter()
|
||||||
|
.find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
validate_device(
|
validate_device(
|
||||||
@@ -386,8 +446,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// List registered devices
|
// List registered devices
|
||||||
let devices: Vec<Device> =
|
let devices: Vec<Device> = list_devices(
|
||||||
list_devices(Some(true), None, None, None, None, None, None, None, None, None)
|
Some(true),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -397,22 +467,22 @@ mod tests {
|
|||||||
// Device aa:aa:aa:aa:aa:aa should not be present
|
// Device aa:aa:aa:aa:aa:aa should not be present
|
||||||
assert!(
|
assert!(
|
||||||
devices
|
devices
|
||||||
.iter().find(|item| item.mac_address == "aa:aa:aa:aa:aa:aa")
|
.iter()
|
||||||
|
.find(|item| item.mac_address == "aa:aa:aa:aa:aa:aa")
|
||||||
.is_none(),
|
.is_none(),
|
||||||
"Device aa:aa:aa:aa:aa:aa should not be present"
|
"Device aa:aa:aa:aa:aa:aa should not be present"
|
||||||
);
|
);
|
||||||
|
|
||||||
// All devices should be registered
|
// All devices should be registered
|
||||||
assert!(
|
assert!(
|
||||||
devices
|
devices.iter().find(|item| !item.is_registered).is_none(),
|
||||||
.iter().find(|item| !item.is_registered)
|
|
||||||
.is_none(),
|
|
||||||
"There should not be any non-registered devices"
|
"There should not be any non-registered devices"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate 1 device
|
// Validate 1 device
|
||||||
let device = devices
|
let device = devices
|
||||||
.iter().find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
|
.iter()
|
||||||
|
.find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
validate_device(
|
validate_device(
|
||||||
@@ -479,7 +549,8 @@ mod tests {
|
|||||||
assert!(!devices.is_empty(), "There should be at least one device");
|
assert!(!devices.is_empty(), "There should be at least one device");
|
||||||
assert!(
|
assert!(
|
||||||
devices
|
devices
|
||||||
.iter().find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
|
.iter()
|
||||||
|
.find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
|
||||||
.is_some(),
|
.is_some(),
|
||||||
"Device bb:bb:bb:bb:bb:bb should be present"
|
"Device bb:bb:bb:bb:bb:bb should be present"
|
||||||
);
|
);
|
||||||
@@ -526,7 +597,10 @@ mod tests {
|
|||||||
let macs_desc: Vec<&str> = by_mac_desc.iter().map(|d| d.mac_address.as_str()).collect();
|
let macs_desc: Vec<&str> = by_mac_desc.iter().map(|d| d.mac_address.as_str()).collect();
|
||||||
let mut sorted_macs_desc = macs_desc.clone();
|
let mut sorted_macs_desc = macs_desc.clone();
|
||||||
sorted_macs_desc.sort_by(|a, b| b.cmp(a));
|
sorted_macs_desc.sort_by(|a, b| b.cmp(a));
|
||||||
assert_eq!(macs_desc, sorted_macs_desc, "mac_address desc should be sorted reversed");
|
assert_eq!(
|
||||||
|
macs_desc, sorted_macs_desc,
|
||||||
|
"mac_address desc should be sorted reversed"
|
||||||
|
);
|
||||||
|
|
||||||
// Sort by owner ascending — empty-string owners (unregistered) come first lexically.
|
// Sort by owner ascending — empty-string owners (unregistered) come first lexically.
|
||||||
let by_owner_asc = list_devices(
|
let by_owner_asc = list_devices(
|
||||||
@@ -561,9 +635,13 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let default_sorted = list_devices(None, None, None, None, None, None, None, None, None, None).unwrap();
|
let default_sorted =
|
||||||
|
list_devices(None, None, None, None, None, None, None, None, None, None).unwrap();
|
||||||
let invalid_macs: Vec<&str> = invalid.iter().map(|d| d.mac_address.as_str()).collect();
|
let invalid_macs: Vec<&str> = invalid.iter().map(|d| d.mac_address.as_str()).collect();
|
||||||
let default_macs: Vec<&str> = default_sorted.iter().map(|d| d.mac_address.as_str()).collect();
|
let default_macs: Vec<&str> = default_sorted
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.mac_address.as_str())
|
||||||
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
invalid_macs, default_macs,
|
invalid_macs, default_macs,
|
||||||
"invalid sort_by must fall back to the default ordering"
|
"invalid sort_by must fall back to the default ordering"
|
||||||
@@ -618,6 +696,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_count_devices() {
|
||||||
|
tests_common::setup().await;
|
||||||
|
|
||||||
|
// Count must match an unpaginated list, independent of paging, for both
|
||||||
|
// no filter and a representative filter combination.
|
||||||
|
let total = count_devices(None, None, None, None, None, None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
total,
|
||||||
|
list_devices(None, None, None, None, None, None, None, None, None, None)
|
||||||
|
.unwrap()
|
||||||
|
.len() as i64,
|
||||||
|
"Count of all devices should match the unpaginated list length"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Paging the list must not change the count.
|
||||||
|
let total_paged = count_devices(None, None, None, None, None, None).unwrap();
|
||||||
|
assert_eq!(total_paged, total, "Count should ignore paging");
|
||||||
|
|
||||||
|
// Filtered count matches the filtered unpaginated list.
|
||||||
|
let registered = count_devices(Some(true), None, None, None, None, None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
registered,
|
||||||
|
list_devices(
|
||||||
|
Some(true),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.len() as i64,
|
||||||
|
"Count of registered devices should match the filtered list length"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
registered <= total,
|
||||||
|
"Filtered count should not exceed the total"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_seen() {
|
async fn test_seen() {
|
||||||
tests_common::setup().await;
|
tests_common::setup().await;
|
||||||
|
|||||||
@@ -62,6 +62,30 @@ pub fn list(
|
|||||||
Ok(notifications)
|
Ok(notifications)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Counts the notifications matching the given filter, ignoring paging. Used to
|
||||||
|
// report the total number of pages alongside a `list` page.
|
||||||
|
pub fn count(is_new: Option<bool>) -> Result<i64, DbError> {
|
||||||
|
debug!("Counting notifications");
|
||||||
|
let conn = db::get_db_connection();
|
||||||
|
|
||||||
|
let mut sql_statement = "SELECT COUNT(*) FROM notifications WHERE 1=1".to_string();
|
||||||
|
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(is_new) = is_new {
|
||||||
|
debug!("Adding filter is_new={}", is_new);
|
||||||
|
sql_statement.push_str(" AND is_new=?");
|
||||||
|
params.push(is_new.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let count: i64 = conn.query_row(
|
||||||
|
sql_statement.as_str(),
|
||||||
|
params_from_iter(params.iter()),
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn insert(notification: Notification) -> Result<i64, DbError> {
|
pub fn insert(notification: Notification) -> Result<i64, DbError> {
|
||||||
let conn = db::get_db_connection();
|
let conn = db::get_db_connection();
|
||||||
let mac_address = notification.mac_address.as_deref().map(normalize_mac);
|
let mac_address = notification.mac_address.as_deref().map(normalize_mac);
|
||||||
@@ -360,10 +384,7 @@ mod tests {
|
|||||||
NotificationType::DeviceOnlineAfterTime,
|
NotificationType::DeviceOnlineAfterTime,
|
||||||
"Wrong notification type (should be DeviceOnlineAfterTime)"
|
"Wrong notification type (should be DeviceOnlineAfterTime)"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(inserted_notification.is_new, "Notification should be new");
|
||||||
inserted_notification.is_new,
|
|
||||||
"Notification should be new"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
inserted_notification.created_on, created_on,
|
inserted_notification.created_on, created_on,
|
||||||
"Wrong created_on (should be ${created_on})"
|
"Wrong created_on (should be ${created_on})"
|
||||||
@@ -502,10 +523,7 @@ mod tests {
|
|||||||
NotificationType::DeviceOnlineAfterTime,
|
NotificationType::DeviceOnlineAfterTime,
|
||||||
"Wrong notification type (should be DeviceOnlineAfterTime)"
|
"Wrong notification type (should be DeviceOnlineAfterTime)"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(inserted_notification.is_new, "Notification should be new");
|
||||||
inserted_notification.is_new,
|
|
||||||
"Notification should be new"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
inserted_notification.created_on, created_on,
|
inserted_notification.created_on, created_on,
|
||||||
"Wrong created_on (should be ${created_on})"
|
"Wrong created_on (should be ${created_on})"
|
||||||
@@ -576,7 +594,8 @@ mod tests {
|
|||||||
|
|
||||||
// Check date of notification 1
|
// Check date of notification 1
|
||||||
let notification1 = notifications
|
let notification1 = notifications
|
||||||
.iter().find(|notification| notification.id == 1)
|
.iter()
|
||||||
|
.find(|notification| notification.id == 1)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// 2026-01-03 14:13:12 - UTC
|
// 2026-01-03 14:13:12 - UTC
|
||||||
@@ -588,7 +607,8 @@ mod tests {
|
|||||||
|
|
||||||
// Check title of notification 3
|
// Check title of notification 3
|
||||||
let notification3 = notifications
|
let notification3 = notifications
|
||||||
.iter().find(|notification| notification.id == 3)
|
.iter()
|
||||||
|
.find(|notification| notification.id == 3)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -599,7 +619,8 @@ mod tests {
|
|||||||
|
|
||||||
// Check body of notification 5
|
// Check body of notification 5
|
||||||
let notification5 = notifications
|
let notification5 = notifications
|
||||||
.iter().find(|notification| notification.id == 5)
|
.iter()
|
||||||
|
.find(|notification| notification.id == 5)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -648,4 +669,44 @@ mod tests {
|
|||||||
"Second page should have at least 1 notification"
|
"Second page should have at least 1 notification"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_count() {
|
||||||
|
tests_common::setup().await;
|
||||||
|
|
||||||
|
// The count must match an unpaginated list for each filter.
|
||||||
|
let all = count(None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
all,
|
||||||
|
list(None, None, None).unwrap().len() as i64,
|
||||||
|
"Count of all notifications should match the unpaginated list length"
|
||||||
|
);
|
||||||
|
|
||||||
|
let new = count(Some(true)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
new,
|
||||||
|
list(Some(true), None, None).unwrap().len() as i64,
|
||||||
|
"Count of new notifications should match the unpaginated list length"
|
||||||
|
);
|
||||||
|
|
||||||
|
let old = count(Some(false)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
old,
|
||||||
|
list(Some(false), None, None).unwrap().len() as i64,
|
||||||
|
"Count of old notifications should match the unpaginated list length"
|
||||||
|
);
|
||||||
|
|
||||||
|
// New + old must add up to the total, and the count must be independent
|
||||||
|
// of paging.
|
||||||
|
assert_eq!(
|
||||||
|
new + old,
|
||||||
|
all,
|
||||||
|
"New and old counts should add up to the total"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
count(Some(true)).unwrap(),
|
||||||
|
new,
|
||||||
|
"Count should ignore paging parameters"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ pub struct Device {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A page of devices plus the total number of devices matching the request's
|
||||||
|
// filters, so the front-end can show how many pages exist.
|
||||||
|
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct DeviceListResponse {
|
||||||
|
pub items: Vec<Device>,
|
||||||
|
pub total_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
impl Device {
|
impl Device {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
mac_address: String,
|
mac_address: String,
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ pub struct Notification {
|
|||||||
pub mac_address: Option<String>,
|
pub mac_address: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A page of notifications plus the total number of notifications matching the
|
||||||
|
// request's filters, so the front-end can show how many pages exist.
|
||||||
|
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct NotificationListResponse {
|
||||||
|
pub items: Vec<Notification>,
|
||||||
|
pub total_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
impl Notification {
|
impl Notification {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
created_on: DateTime<Utc>,
|
created_on: DateTime<Utc>,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::error::Error;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
|
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
|
||||||
use crate::model::devices::{Device, DeviceSummary};
|
use crate::model::devices::{Device, DeviceListResponse, DeviceSummary};
|
||||||
use crate::model::notifications::{Notification, NotificationType};
|
use crate::model::notifications::{Notification, NotificationListResponse, NotificationType};
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
|
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
|
||||||
use crate::web_server::scanner_status::{
|
use crate::web_server::scanner_status::{
|
||||||
@@ -66,8 +66,10 @@ pub mod utils;
|
|||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
Device,
|
Device,
|
||||||
|
DeviceListResponse,
|
||||||
DeviceSummary,
|
DeviceSummary,
|
||||||
Notification,
|
Notification,
|
||||||
|
NotificationListResponse,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
RegisterDevicePayload,
|
RegisterDevicePayload,
|
||||||
UpdateDevicePayload,
|
UpdateDevicePayload,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use utoipa::ToSchema;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db,
|
db,
|
||||||
model::devices::{Device, DeviceSummary},
|
model::devices::{Device, DeviceListResponse, DeviceSummary},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::web_server::utils;
|
use crate::web_server::utils;
|
||||||
@@ -32,14 +32,14 @@ use crate::web_server::utils;
|
|||||||
("page_limit" = Option<i64>, Query, description = "Maximum number of results to return"),
|
("page_limit" = Option<i64>, Query, description = "Maximum number of results to return"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "List of devices", body = Vec<Device>),
|
(status = 200, description = "List of devices", body = DeviceListResponse),
|
||||||
(status = 500, description = "Internal server error"),
|
(status = 500, description = "Internal server error"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn list(
|
pub async fn list(
|
||||||
Query(params): Query<HashMap<String, String>>,
|
Query(params): Query<HashMap<String, String>>,
|
||||||
) -> Result<Json<Vec<Device>>, StatusCode> {
|
) -> Result<Json<DeviceListResponse>, StatusCode> {
|
||||||
let is_registered: Option<bool> = utils::parse_parameter_bool(¶ms, "is_registered");
|
let is_registered: Option<bool> = utils::parse_parameter_bool(¶ms, "is_registered");
|
||||||
let last_seen_from: Option<DateTime<Utc>> =
|
let last_seen_from: Option<DateTime<Utc>> =
|
||||||
utils::parse_parameter_date(¶ms, "last_seen_from");
|
utils::parse_parameter_date(¶ms, "last_seen_from");
|
||||||
@@ -52,24 +52,41 @@ pub async fn list(
|
|||||||
let page_offset: Option<i64> = utils::parse_parameter_int(¶ms, "page_offset");
|
let page_offset: Option<i64> = utils::parse_parameter_int(¶ms, "page_offset");
|
||||||
let page_limit: Option<i64> = utils::parse_parameter_int(¶ms, "page_limit");
|
let page_limit: Option<i64> = utils::parse_parameter_int(¶ms, "page_limit");
|
||||||
|
|
||||||
match db::devices::list_devices(
|
let items = match db::devices::list_devices(
|
||||||
|
is_registered,
|
||||||
|
last_seen_from,
|
||||||
|
last_seen_to,
|
||||||
|
owner.clone(),
|
||||||
|
device_type.clone(),
|
||||||
|
vendor.clone(),
|
||||||
|
sort_by,
|
||||||
|
sort_order,
|
||||||
|
page_offset,
|
||||||
|
page_limit,
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Error listing devices: {}", err);
|
||||||
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_count = match db::devices::count_devices(
|
||||||
is_registered,
|
is_registered,
|
||||||
last_seen_from,
|
last_seen_from,
|
||||||
last_seen_to,
|
last_seen_to,
|
||||||
owner,
|
owner,
|
||||||
device_type,
|
device_type,
|
||||||
vendor,
|
vendor,
|
||||||
sort_by,
|
|
||||||
sort_order,
|
|
||||||
page_offset,
|
|
||||||
page_limit,
|
|
||||||
) {
|
) {
|
||||||
Ok(value) => Ok(Json(value)),
|
Ok(value) => value,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Error listing devices: {}", err);
|
error!("Error counting devices: {}", err);
|
||||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(DeviceListResponse { items, total_count }))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use log::error;
|
use log::error;
|
||||||
|
|
||||||
use crate::{db, model::notifications::Notification, web_server::utils};
|
use crate::{
|
||||||
|
db,
|
||||||
|
model::notifications::{Notification, NotificationListResponse},
|
||||||
|
web_server::utils,
|
||||||
|
};
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -118,23 +122,33 @@ pub async fn mark_all_as_old() -> impl IntoResponse {
|
|||||||
("page_limit" = Option<i64>, Query, description = "Maximum number of results to return"),
|
("page_limit" = Option<i64>, Query, description = "Maximum number of results to return"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "List of notifications", body = Vec<Notification>),
|
(status = 200, description = "List of notifications", body = NotificationListResponse),
|
||||||
(status = 500, description = "Internal server error"),
|
(status = 500, description = "Internal server error"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn list(
|
pub async fn list(
|
||||||
Query(params): Query<HashMap<String, String>>,
|
Query(params): Query<HashMap<String, String>>,
|
||||||
) -> Result<Json<Vec<Notification>>, StatusCode> {
|
) -> Result<Json<NotificationListResponse>, StatusCode> {
|
||||||
let is_new: Option<bool> = utils::parse_parameter_bool(¶ms, "is_new");
|
let is_new: Option<bool> = utils::parse_parameter_bool(¶ms, "is_new");
|
||||||
let page_offset: Option<i64> = utils::parse_parameter_int(¶ms, "page_offset");
|
let page_offset: Option<i64> = utils::parse_parameter_int(¶ms, "page_offset");
|
||||||
let page_limit: Option<i64> = utils::parse_parameter_int(¶ms, "page_limit");
|
let page_limit: Option<i64> = utils::parse_parameter_int(¶ms, "page_limit");
|
||||||
|
|
||||||
match db::notifications::list(is_new, page_offset, page_limit) {
|
let items = match db::notifications::list(is_new, page_offset, page_limit) {
|
||||||
Ok(value) => Ok(Json(value)),
|
Ok(value) => value,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Error listing notifications: {}", err);
|
error!("Error listing notifications: {}", err);
|
||||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_count = match db::notifications::count(is_new) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Error counting notifications: {}", err);
|
||||||
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(NotificationListResponse { items, total_count }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
bool _sortAscending = false;
|
bool _sortAscending = false;
|
||||||
|
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
bool _hasNextPage = false;
|
// Total devices matching the current filters, used to show how many pages
|
||||||
|
// exist and to offer "go to last page".
|
||||||
|
int _totalCount = 0;
|
||||||
bool _didInitialFetch = false;
|
bool _didInitialFetch = false;
|
||||||
CancelToken? _fetchToken;
|
CancelToken? _fetchToken;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
@@ -54,6 +56,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
|
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
|
||||||
? _phonePageSize
|
? _phonePageSize
|
||||||
: _widePageSize;
|
: _widePageSize;
|
||||||
|
int get _totalPages => (_totalCount / _pageSize).ceil().clamp(1, 1 << 30);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -145,7 +148,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
if (!mounted || token != _fetchToken) return;
|
if (!mounted || token != _fetchToken) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentPage = page;
|
_currentPage = page;
|
||||||
_hasNextPage = result.hasNextPage;
|
_totalCount = result.totalCount;
|
||||||
_devices = result.items;
|
_devices = result.items;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_isPaging = false;
|
_isPaging = false;
|
||||||
@@ -349,11 +352,11 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
separatorBuilder: (_, _) =>
|
separatorBuilder: (_, _) =>
|
||||||
isWide ? const Divider(height: 1) : const SizedBox.shrink(),
|
isWide ? const Divider(height: 1) : const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
if (_currentPage > 0 || _hasNextPage)
|
if (_currentPage > 0 || _totalPages > 1)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: PaginationBar(
|
child: PaginationBar(
|
||||||
currentPage: _currentPage,
|
currentPage: _currentPage,
|
||||||
hasNextPage: _hasNextPage,
|
totalPages: _totalPages,
|
||||||
isLoading: _isPaging,
|
isLoading: _isPaging,
|
||||||
onPageChanged: (page) =>
|
onPageChanged: (page) =>
|
||||||
_fetchPage(page, scrollToTop: true, paging: true),
|
_fetchPage(page, scrollToTop: true, paging: true),
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
|
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
|
||||||
? _phonePageSize
|
? _phonePageSize
|
||||||
: _widePageSize;
|
: _widePageSize;
|
||||||
bool _hasNextPage = false;
|
// Total notifications matching the current filter, used to show how many pages
|
||||||
|
// exist and to offer "go to last page".
|
||||||
|
int _totalCount = 0;
|
||||||
|
int get _totalPages => (_totalCount / _pageSize).ceil().clamp(1, 1 << 30);
|
||||||
String? _error;
|
String? _error;
|
||||||
CancelToken? _fetchToken;
|
CancelToken? _fetchToken;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
@@ -189,7 +192,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
// without setState here; _reconcile schedules the rebuild itself so it
|
// without setState here; _reconcile schedules the rebuild itself so it
|
||||||
// can defer swapping in the empty state until exit animations finish.
|
// can defer swapping in the empty state until exit animations finish.
|
||||||
_currentPage = page;
|
_currentPage = page;
|
||||||
_hasNextPage = result.hasNextPage;
|
_totalCount = result.totalCount;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_isPaging = false;
|
_isPaging = false;
|
||||||
_reconcile(result.items);
|
_reconcile(result.items);
|
||||||
@@ -200,7 +203,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_listKey = GlobalKey();
|
_listKey = GlobalKey();
|
||||||
_currentPage = page;
|
_currentPage = page;
|
||||||
_hasNextPage = result.hasNextPage;
|
_totalCount = result.totalCount;
|
||||||
_items = result.items;
|
_items = result.items;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_isPaging = false;
|
_isPaging = false;
|
||||||
@@ -293,10 +296,15 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes an item in response to a card action, then refreshes the
|
/// Removes an item in response to a card action (swipe or mark read/unread
|
||||||
/// surrounding chrome (header button, pagination, empty state).
|
/// that drops it from the current filter), then refreshes the surrounding
|
||||||
|
/// chrome (header button, pagination, empty state). The total is decremented
|
||||||
|
/// locally so the page count stays accurate without re-fetching; background
|
||||||
|
/// refreshes re-sync it from the backend. Removals driven by [_reconcile] use
|
||||||
|
/// [_removeItem] directly so they don't double-count against a fresh total.
|
||||||
void _removeAndSettle(int id, {required bool animated}) {
|
void _removeAndSettle(int id, {required bool animated}) {
|
||||||
_removeItem(id, animated: animated);
|
_removeItem(id, animated: animated);
|
||||||
|
if (_totalCount > 0) _totalCount--;
|
||||||
_afterStructuralChange();
|
_afterStructuralChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,11 +444,11 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
_buildNotificationSliver(),
|
_buildNotificationSliver(),
|
||||||
if (_currentPage > 0 || _hasNextPage)
|
if (_currentPage > 0 || _totalPages > 1)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: PaginationBar(
|
child: PaginationBar(
|
||||||
currentPage: _currentPage,
|
currentPage: _currentPage,
|
||||||
hasNextPage: _hasNextPage,
|
totalPages: _totalPages,
|
||||||
isLoading: _isPaging,
|
isLoading: _isPaging,
|
||||||
onPageChanged: (page) =>
|
onPageChanged: (page) =>
|
||||||
_fetchPage(page, scrollToTop: true, paging: true),
|
_fetchPage(page, scrollToTop: true, paging: true),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ extension DeviceApi on BackendAPI {
|
|||||||
Future<Device> getDevice(String macAddress) =>
|
Future<Device> getDevice(String macAddress) =>
|
||||||
_getModel('/devices/$macAddress', Device.fromJson);
|
_getModel('/devices/$macAddress', Device.fromJson);
|
||||||
|
|
||||||
Future<({List<Device> items, bool hasNextPage})> listDevices({
|
Future<({List<Device> items, int totalCount})> listDevices({
|
||||||
bool? isRegistered,
|
bool? isRegistered,
|
||||||
String? owner,
|
String? owner,
|
||||||
DeviceType? deviceType,
|
DeviceType? deviceType,
|
||||||
@@ -28,7 +28,7 @@ extension DeviceApi on BackendAPI {
|
|||||||
params['sort_order'] = sortAscending ? 'asc' : 'desc';
|
params['sort_order'] = sortAscending ? 'asc' : 'desc';
|
||||||
}
|
}
|
||||||
params['page_offset'] = page * perPage;
|
params['page_offset'] = page * perPage;
|
||||||
params['page_limit'] = perPage + 1;
|
params['page_limit'] = perPage;
|
||||||
|
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
'/devices',
|
'/devices',
|
||||||
@@ -37,9 +37,8 @@ extension DeviceApi on BackendAPI {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return _paginate(
|
return _paginate(
|
||||||
response.data as List,
|
response.data as Map<String, dynamic>,
|
||||||
(item) => Device.fromJson(item as Map<String, dynamic>),
|
(item) => Device.fromJson(item as Map<String, dynamic>),
|
||||||
perPage,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ extension NotificationApi on BackendAPI {
|
|||||||
await _dio.post('/notifications/mark_all_as_old');
|
await _dio.post('/notifications/mark_all_as_old');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<({List<Notification> items, bool hasNextPage})> listNotifications(
|
Future<({List<Notification> items, int totalCount})> listNotifications(
|
||||||
bool? isNew, {
|
bool? isNew, {
|
||||||
int page = 0,
|
int page = 0,
|
||||||
int perPage = 5,
|
int perPage = 5,
|
||||||
@@ -25,15 +25,14 @@ extension NotificationApi on BackendAPI {
|
|||||||
queryParameters: {
|
queryParameters: {
|
||||||
'is_new': isNew ?? '',
|
'is_new': isNew ?? '',
|
||||||
'page_offset': page * perPage,
|
'page_offset': page * perPage,
|
||||||
'page_limit': perPage + 1,
|
'page_limit': perPage,
|
||||||
},
|
},
|
||||||
cancelToken: cancelToken,
|
cancelToken: cancelToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
return _paginate(
|
return _paginate(
|
||||||
response.data as List<dynamic>,
|
response.data as Map<String, dynamic>,
|
||||||
(item) => Notification.fromJson(item),
|
(item) => Notification.fromJson(item),
|
||||||
perPage,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,19 +87,15 @@ class BackendAPI {
|
|||||||
return fromJson(response.data as Map<String, dynamic>);
|
return fromJson(response.data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits a "fetch one extra item" page into its items and a [hasNextPage]
|
/// Decodes a paged list response of the shape `{items: [...], total_count: N}`
|
||||||
/// flag. Callers request `perPage + 1` items so a full extra item signals
|
/// into the page's items and the total number of rows matching the request's
|
||||||
/// that another page exists.
|
/// filters. The total lets callers report how many pages exist and offer a
|
||||||
({List<T> items, bool hasNextPage}) _paginate<T>(
|
/// "go to last page" control.
|
||||||
List<dynamic> data,
|
({List<T> items, int totalCount}) _paginate<T>(
|
||||||
|
Map<String, dynamic> data,
|
||||||
T Function(dynamic) fromItem,
|
T Function(dynamic) fromItem,
|
||||||
int perPage,
|
|
||||||
) {
|
) {
|
||||||
final results = data.map(fromItem).toList();
|
final items = (data['items'] as List<dynamic>).map(fromItem).toList();
|
||||||
final hasNextPage = results.length > perPage;
|
return (items: items, totalCount: data['total_count'] as int);
|
||||||
return (
|
|
||||||
items: hasNextPage ? results.take(perPage).toList() : results,
|
|
||||||
hasNextPage: hasNextPage,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/dimens.dart';
|
||||||
|
|
||||||
class PaginationBar extends StatelessWidget {
|
class PaginationBar extends StatelessWidget {
|
||||||
const PaginationBar({
|
const PaginationBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.currentPage,
|
required this.currentPage,
|
||||||
required this.hasNextPage,
|
required this.totalPages,
|
||||||
required this.isLoading,
|
required this.isLoading,
|
||||||
required this.onPageChanged,
|
required this.onPageChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int currentPage;
|
final int currentPage;
|
||||||
final bool hasNextPage;
|
final int totalPages;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final ValueChanged<int> onPageChanged;
|
final ValueChanged<int> onPageChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final lastPage = totalPages - 1;
|
||||||
final canGoBack = currentPage > 0 && !isLoading;
|
final canGoBack = currentPage > 0 && !isLoading;
|
||||||
final canGoForward = hasNextPage && !isLoading;
|
final canGoForward = currentPage < lastPage && !isLoading;
|
||||||
|
// Phones don't have room for the verbose label, so they get the compact
|
||||||
|
// "X / Y" form; wider layouts spell it out.
|
||||||
|
final isWide = MediaQuery.sizeOf(context).width >= Breakpoints.medium;
|
||||||
|
final label = isWide
|
||||||
|
? 'Page ${currentPage + 1} of $totalPages'
|
||||||
|
: '${currentPage + 1} / $totalPages';
|
||||||
// While a page change is in flight the buttons disable so the tap reads as
|
// While a page change is in flight the buttons disable so the tap reads as
|
||||||
// registered and double-taps are blocked; the progress cue itself is drawn
|
// registered and double-taps are blocked; the progress cue itself is drawn
|
||||||
// by the app shell at the bottom of the page body.
|
// by the app shell at the bottom of the page body.
|
||||||
@@ -39,10 +48,7 @@ class PaginationBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
'Page ${currentPage + 1}',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
IconButton.outlined(
|
IconButton.outlined(
|
||||||
onPressed: canGoForward
|
onPressed: canGoForward
|
||||||
@@ -51,6 +57,12 @@ class PaginationBar extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_right),
|
||||||
tooltip: 'Next page',
|
tooltip: 'Next page',
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.outlined(
|
||||||
|
onPressed: canGoForward ? () => onPageChanged(lastPage) : null,
|
||||||
|
icon: const Icon(Icons.last_page),
|
||||||
|
tooltip: 'Last page',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,22 +35,25 @@ void main() {
|
|||||||
expect(summary.totalRegistered, 7);
|
expect(summary.totalRegistered, 7);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listDevices sends filter + pagination params and trims extra item',
|
test(
|
||||||
|
'listDevices sends filter + pagination params and reports the total',
|
||||||
() async {
|
() async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/devices',
|
'/devices',
|
||||||
(server) => server.reply(200, [
|
(server) => server.reply(
|
||||||
|
200,
|
||||||
|
pagedListJson([
|
||||||
deviceJson(macAddress: '00:00:00:00:00:01'),
|
deviceJson(macAddress: '00:00:00:00:00:01'),
|
||||||
deviceJson(macAddress: '00:00:00:00:00:02'),
|
deviceJson(macAddress: '00:00:00:00:00:02'),
|
||||||
deviceJson(macAddress: '00:00:00:00:00:03'),
|
], totalCount: 12),
|
||||||
]),
|
),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'is_registered': false,
|
'is_registered': false,
|
||||||
'device_type': 'laptop',
|
'device_type': 'laptop',
|
||||||
'sort_by': 'last_seen',
|
'sort_by': 'last_seen',
|
||||||
'sort_order': 'asc',
|
'sort_order': 'asc',
|
||||||
'page_offset': 0,
|
'page_offset': 0,
|
||||||
'page_limit': 3,
|
'page_limit': 2,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -64,31 +67,30 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.items, hasLength(2));
|
expect(result.items, hasLength(2));
|
||||||
expect(result.hasNextPage, isTrue);
|
expect(result.totalCount, 12);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('listDevices reports no next page when fewer than perPage+1 returned',
|
test(
|
||||||
|
'listDevices reports the total when a single page is returned',
|
||||||
() async {
|
() async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/devices',
|
'/devices',
|
||||||
(server) => server.reply(200, [deviceJson()]),
|
(server) => server.reply(200, pagedListJson([deviceJson()])),
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await BackendAPI.instance.listDevices(perPage: 2);
|
final result = await BackendAPI.instance.listDevices(perPage: 2);
|
||||||
|
|
||||||
expect(result.items, hasLength(1));
|
expect(result.items, hasLength(1));
|
||||||
expect(result.hasNextPage, isFalse);
|
expect(result.totalCount, 1);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('listDevices maps the unknown device type to an empty filter', () async {
|
test('listDevices maps the unknown device type to an empty filter', () async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/devices',
|
'/devices',
|
||||||
(server) => server.reply(200, <dynamic>[]),
|
(server) => server.reply(200, pagedListJson([])),
|
||||||
queryParameters: {
|
queryParameters: {'device_type': '', 'page_offset': 0, 'page_limit': 10},
|
||||||
'device_type': '',
|
|
||||||
'page_offset': 0,
|
|
||||||
'page_limit': 11,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await BackendAPI.instance.listDevices(
|
final result = await BackendAPI.instance.listDevices(
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('markNotificationAsRead GETs the notification path', () async {
|
test('markNotificationAsRead GETs the notification path', () async {
|
||||||
adapter.onGet(
|
adapter.onGet('/notifications/7', (server) => server.reply(200, null));
|
||||||
'/notifications/7',
|
|
||||||
(server) => server.reply(200, null),
|
|
||||||
);
|
|
||||||
|
|
||||||
await BackendAPI.instance.markNotificationAsRead(7);
|
await BackendAPI.instance.markNotificationAsRead(7);
|
||||||
});
|
});
|
||||||
@@ -39,20 +36,19 @@ void main() {
|
|||||||
await BackendAPI.instance.markAllNotificationsAsRead();
|
await BackendAPI.instance.markAllNotificationsAsRead();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listNotifications sends is_new + pagination and trims extra item',
|
test(
|
||||||
|
'listNotifications sends is_new + pagination and reports the total',
|
||||||
() async {
|
() async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, [
|
(server) => server.reply(
|
||||||
|
200,
|
||||||
|
pagedListJson([
|
||||||
notificationJson(id: 1),
|
notificationJson(id: 1),
|
||||||
notificationJson(id: 2),
|
notificationJson(id: 2),
|
||||||
notificationJson(id: 3),
|
], totalCount: 9),
|
||||||
]),
|
),
|
||||||
queryParameters: {
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 2},
|
||||||
'is_new': true,
|
|
||||||
'page_offset': 0,
|
|
||||||
'page_limit': 3,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await BackendAPI.instance.listNotifications(
|
final result = await BackendAPI.instance.listNotifications(
|
||||||
@@ -62,19 +58,17 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.items, hasLength(2));
|
expect(result.items, hasLength(2));
|
||||||
expect(result.hasNextPage, isTrue);
|
expect(result.totalCount, 9);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('listNotifications maps a null filter to an empty is_new param',
|
test(
|
||||||
|
'listNotifications maps a null filter to an empty is_new param',
|
||||||
() async {
|
() async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, <dynamic>[]),
|
(server) => server.reply(200, pagedListJson([])),
|
||||||
queryParameters: {
|
queryParameters: {'is_new': '', 'page_offset': 10, 'page_limit': 5},
|
||||||
'is_new': '',
|
|
||||||
'page_offset': 10,
|
|
||||||
'page_limit': 6,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await BackendAPI.instance.listNotifications(
|
final result = await BackendAPI.instance.listNotifications(
|
||||||
@@ -84,6 +78,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.items, isEmpty);
|
expect(result.items, isEmpty);
|
||||||
expect(result.hasNextPage, isFalse);
|
expect(result.totalCount, 0);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ Map<String, dynamic> notificationJson({
|
|||||||
'mac_address': macAddress,
|
'mac_address': macAddress,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Wraps a page of items in the paged-list response shape the list endpoints
|
||||||
|
/// return: `{items: [...], total_count: N}`. [totalCount] defaults to the page
|
||||||
|
/// length, so single-page stubs stay terse; pass it to simulate more pages.
|
||||||
|
Map<String, dynamic> pagedListJson(
|
||||||
|
List<Map<String, dynamic>> items, {
|
||||||
|
int? totalCount,
|
||||||
|
}) => {'items': items, 'total_count': totalCount ?? items.length};
|
||||||
|
|
||||||
/// Shape shared by the ARP and SNMP scanners (run-on-interval scanners).
|
/// Shape shared by the ARP and SNMP scanners (run-on-interval scanners).
|
||||||
Map<String, dynamic> intervalScannerJson({
|
Map<String, dynamic> intervalScannerJson({
|
||||||
bool isRunning = false,
|
bool isRunning = false,
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ void main() {
|
|||||||
testWidgets('renders device rows after loading', (tester) async {
|
testWidgets('renders device rows after loading', (tester) async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/devices',
|
'/devices',
|
||||||
(server) => server.reply(200, [
|
(server) => server.reply(
|
||||||
|
200,
|
||||||
|
pagedListJson([
|
||||||
deviceJson(macAddress: '00:00:00:00:00:01', owner: 'alice'),
|
deviceJson(macAddress: '00:00:00:00:00:01', owner: 'alice'),
|
||||||
deviceJson(macAddress: '00:00:00:00:00:02', owner: 'bob'),
|
deviceJson(macAddress: '00:00:00:00:00:02', owner: 'bob'),
|
||||||
]),
|
]),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await pumpScreen(tester, const DeviceList());
|
await pumpScreen(tester, const DeviceList());
|
||||||
@@ -35,7 +38,7 @@ void main() {
|
|||||||
testWidgets('shows the empty message when there are no devices', (
|
testWidgets('shows the empty message when there are no devices', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
adapter.onGet('/devices', (server) => server.reply(200, <dynamic>[]));
|
adapter.onGet('/devices', (server) => server.reply(200, pagedListJson([])));
|
||||||
|
|
||||||
await pumpScreen(tester, const DeviceList());
|
await pumpScreen(tester, const DeviceList());
|
||||||
await pumpUntilFound(tester, find.text('No unregistered devices'));
|
await pumpUntilFound(tester, find.text('No unregistered devices'));
|
||||||
@@ -63,17 +66,20 @@ void main() {
|
|||||||
testWidgets('changing pages scrolls back to the top of the list', (
|
testWidgets('changing pages scrolls back to the top of the list', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
// 11 devices: a full page of 10 plus one, so the pagination bar appears.
|
// A total of 11 across two pages of 10, so the pagination bar appears.
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/devices',
|
'/devices',
|
||||||
(server) => server.reply(
|
(server) => server.reply(
|
||||||
200,
|
200,
|
||||||
|
pagedListJson(
|
||||||
List.generate(
|
List.generate(
|
||||||
11,
|
10,
|
||||||
(i) => deviceJson(
|
(i) => deviceJson(
|
||||||
macAddress: '00:00:00:00:00:${i.toString().padLeft(2, '0')}',
|
macAddress: '00:00:00:00:00:${i.toString().padLeft(2, '0')}',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
totalCount: 11,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,29 +113,34 @@ void main() {
|
|||||||
// Default ordering (last_seen, desc) returns two devices.
|
// Default ordering (last_seen, desc) returns two devices.
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/devices',
|
'/devices',
|
||||||
(server) => server.reply(200, [
|
(server) => server.reply(
|
||||||
|
200,
|
||||||
|
pagedListJson([
|
||||||
deviceJson(macAddress: '00:00:00:00:00:01'),
|
deviceJson(macAddress: '00:00:00:00:00:01'),
|
||||||
deviceJson(macAddress: '00:00:00:00:00:02'),
|
deviceJson(macAddress: '00:00:00:00:00:02'),
|
||||||
]),
|
]),
|
||||||
|
),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'is_registered': false,
|
'is_registered': false,
|
||||||
'sort_by': 'last_seen',
|
'sort_by': 'last_seen',
|
||||||
'sort_order': 'desc',
|
'sort_order': 'desc',
|
||||||
'page_offset': 0,
|
'page_offset': 0,
|
||||||
'page_limit': 11,
|
'page_limit': 10,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Sorting by device type (asc) returns a single, distinguishable device.
|
// Sorting by device type (asc) returns a single, distinguishable device.
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/devices',
|
'/devices',
|
||||||
(server) =>
|
(server) => server.reply(
|
||||||
server.reply(200, [deviceJson(macAddress: '00:00:00:00:00:03')]),
|
200,
|
||||||
|
pagedListJson([deviceJson(macAddress: '00:00:00:00:00:03')]),
|
||||||
|
),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'is_registered': false,
|
'is_registered': false,
|
||||||
'sort_by': 'device_type',
|
'sort_by': 'device_type',
|
||||||
'sort_order': 'asc',
|
'sort_order': 'asc',
|
||||||
'page_offset': 0,
|
'page_offset': 0,
|
||||||
'page_limit': 11,
|
'page_limit': 10,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -138,9 +149,11 @@ void main() {
|
|||||||
expect(find.byType(DeviceRowWide), findsNWidgets(2));
|
expect(find.byType(DeviceRowWide), findsNWidgets(2));
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Sort by device type'));
|
await tester.tap(find.byTooltip('Sort by device type'));
|
||||||
for (var i = 0;
|
for (
|
||||||
|
var i = 0;
|
||||||
i < 40 && find.byType(DeviceRowWide).evaluate().length != 1;
|
i < 40 && find.byType(DeviceRowWide).evaluate().length != 1;
|
||||||
i++) {
|
i++
|
||||||
|
) {
|
||||||
await tester.pump(const Duration(milliseconds: 10));
|
await tester.pump(const Duration(milliseconds: 10));
|
||||||
}
|
}
|
||||||
expect(find.byType(DeviceRowWide), findsOneWidget);
|
expect(find.byType(DeviceRowWide), findsOneWidget);
|
||||||
@@ -149,7 +162,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('the sort sheet offers ordering by device type', (tester) async {
|
testWidgets('the sort sheet offers ordering by device type', (tester) async {
|
||||||
adapter.onGet('/devices', (server) => server.reply(200, [deviceJson()]));
|
adapter.onGet(
|
||||||
|
'/devices',
|
||||||
|
(server) => server.reply(200, pagedListJson([deviceJson()])),
|
||||||
|
);
|
||||||
|
|
||||||
// A phone-width viewport so the compact layout with the sort button shows.
|
// A phone-width viewport so the compact layout with the sort button shows.
|
||||||
await pumpScreen(tester, const DeviceList(), size: const Size(500, 900));
|
await pumpScreen(tester, const DeviceList(), size: const Size(500, 900));
|
||||||
@@ -169,7 +185,10 @@ void main() {
|
|||||||
final devices = <Map<String, dynamic>>[
|
final devices = <Map<String, dynamic>>[
|
||||||
deviceJson(macAddress: '00:00:00:00:00:01'),
|
deviceJson(macAddress: '00:00:00:00:00:01'),
|
||||||
];
|
];
|
||||||
adapter.onGet('/devices', (server) => server.reply(200, devices));
|
adapter.onGet(
|
||||||
|
'/devices',
|
||||||
|
(server) => server.reply(200, pagedListJson(devices)),
|
||||||
|
);
|
||||||
|
|
||||||
await pumpScreen(tester, const DeviceList());
|
await pumpScreen(tester, const DeviceList());
|
||||||
await pumpUntilFound(tester, find.byType(DeviceRowWide));
|
await pumpUntilFound(tester, find.byType(DeviceRowWide));
|
||||||
@@ -188,4 +207,49 @@ void main() {
|
|||||||
|
|
||||||
await tearDownTree(tester);
|
await tearDownTree(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the last-page button jumps to the final page', (tester) async {
|
||||||
|
// Wide viewport → page size 10. A total of 25 spans three pages.
|
||||||
|
List<Map<String, dynamic>> page(int first, int count) => List.generate(
|
||||||
|
count,
|
||||||
|
(i) => deviceJson(
|
||||||
|
macAddress: '00:00:00:00:00:${(first + i).toString().padLeft(2, '0')}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
adapter.onGet(
|
||||||
|
'/devices',
|
||||||
|
(server) => server.reply(200, pagedListJson(page(0, 10), totalCount: 25)),
|
||||||
|
queryParameters: {
|
||||||
|
'is_registered': false,
|
||||||
|
'sort_by': 'last_seen',
|
||||||
|
'sort_order': 'desc',
|
||||||
|
'page_offset': 0,
|
||||||
|
'page_limit': 10,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
adapter.onGet(
|
||||||
|
'/devices',
|
||||||
|
(server) => server.reply(200, pagedListJson(page(20, 5), totalCount: 25)),
|
||||||
|
queryParameters: {
|
||||||
|
'is_registered': false,
|
||||||
|
'sort_by': 'last_seen',
|
||||||
|
'sort_order': 'desc',
|
||||||
|
'page_offset': 20,
|
||||||
|
'page_limit': 10,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// A tall viewport so the full page of rows and the pagination bar are all
|
||||||
|
// on screen at once (no scrolling needed to reach the last-page button).
|
||||||
|
await pumpScreen(tester, const DeviceList(), size: const Size(900, 2000));
|
||||||
|
await pumpUntilFound(tester, find.byType(DeviceRowWide));
|
||||||
|
expect(find.text('Page 1 of 3'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Last page'));
|
||||||
|
await pumpUntilFound(tester, find.text('Page 3 of 3'));
|
||||||
|
|
||||||
|
expect(find.text('Page 3 of 3'), findsOneWidget);
|
||||||
|
|
||||||
|
await tearDownTree(tester);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ void main() {
|
|||||||
void stubHomeEndpoints() {
|
void stubHomeEndpoints() {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, [
|
(server) => server.reply(
|
||||||
notificationJson(id: 1, title: 'New device found'),
|
200,
|
||||||
]),
|
pagedListJson([notificationJson(id: 1, title: 'New device found')]),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/devices/summary',
|
'/devices/summary',
|
||||||
@@ -47,8 +48,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('shows notifications, the device summary and scanner statuses',
|
testWidgets('shows notifications, the device summary and scanner statuses', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
stubHomeEndpoints();
|
stubHomeEndpoints();
|
||||||
|
|
||||||
// Below the 700px breakpoint the home screen uses its single-column layout,
|
// Below the 700px breakpoint the home screen uses its single-column layout,
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ void main() {
|
|||||||
(tester) async {
|
(tester) async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, [
|
(server) => server.reply(
|
||||||
|
200,
|
||||||
|
pagedListJson([
|
||||||
notificationJson(id: 1, title: 'First', body: 'First body'),
|
notificationJson(id: 1, title: 'First', body: 'First body'),
|
||||||
notificationJson(id: 2, title: 'Second', body: 'Second body'),
|
notificationJson(id: 2, title: 'Second', body: 'Second body'),
|
||||||
]),
|
]),
|
||||||
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
|
),
|
||||||
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
||||||
);
|
);
|
||||||
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
|
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
|
||||||
|
|
||||||
@@ -64,20 +67,20 @@ void main() {
|
|||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
|
||||||
// The 400px-wide viewport above is a phone, so the list uses the smaller
|
// The 400px-wide viewport above is a phone, so the list uses the smaller
|
||||||
// phone page size of 4 (requesting page_limit 5 to detect a next page).
|
// phone page size of 4. A total of 8 spans two pages.
|
||||||
List<Map<String, dynamic>> page(int firstId) => List.generate(
|
List<Map<String, dynamic>> page(int firstId) => List.generate(
|
||||||
5,
|
4,
|
||||||
(i) => notificationJson(id: firstId + i, title: 'Item ${firstId + i}'),
|
(i) => notificationJson(id: firstId + i, title: 'Item ${firstId + i}'),
|
||||||
);
|
);
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, page(1)),
|
(server) => server.reply(200, pagedListJson(page(1), totalCount: 8)),
|
||||||
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 4},
|
||||||
);
|
);
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, page(6)),
|
(server) => server.reply(200, pagedListJson(page(5), totalCount: 8)),
|
||||||
queryParameters: {'is_new': true, 'page_offset': 4, 'page_limit': 5},
|
queryParameters: {'is_new': true, 'page_offset': 4, 'page_limit': 4},
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -100,10 +103,11 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(position().pixels, greaterThan(0));
|
expect(position().pixels, greaterThan(0));
|
||||||
|
|
||||||
// Advance a page: the list should jump back to the top.
|
// Advance a page: the list should jump back to the top, showing page two's
|
||||||
|
// first item (id 5).
|
||||||
await tester.tap(find.byTooltip('Next page'));
|
await tester.tap(find.byTooltip('Next page'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.textContaining('Item 7'), findsWidgets);
|
expect(find.textContaining('Item 5'), findsWidgets);
|
||||||
expect(position().pixels, 0);
|
expect(position().pixels, 0);
|
||||||
|
|
||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
@@ -121,8 +125,8 @@ void main() {
|
|||||||
];
|
];
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, items),
|
(server) => server.reply(200, pagedListJson(items)),
|
||||||
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 4},
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -156,11 +160,14 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, [
|
(server) => server.reply(
|
||||||
|
200,
|
||||||
|
pagedListJson([
|
||||||
notificationJson(id: 1, title: 'First'),
|
notificationJson(id: 1, title: 'First'),
|
||||||
notificationJson(id: 2, title: 'Second'),
|
notificationJson(id: 2, title: 'Second'),
|
||||||
]),
|
]),
|
||||||
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
|
),
|
||||||
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
||||||
);
|
);
|
||||||
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
|
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
|
||||||
|
|
||||||
@@ -208,8 +215,8 @@ void main() {
|
|||||||
];
|
];
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, items),
|
(server) => server.reply(200, pagedListJson(items)),
|
||||||
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 4},
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -262,8 +269,8 @@ void main() {
|
|||||||
];
|
];
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, items),
|
(server) => server.reply(200, pagedListJson(items)),
|
||||||
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 4},
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -295,13 +302,19 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, [notificationJson(id: 1, title: 'Kept')]),
|
(server) => server.reply(
|
||||||
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
|
200,
|
||||||
|
pagedListJson([notificationJson(id: 1, title: 'Kept')]),
|
||||||
|
),
|
||||||
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
||||||
);
|
);
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, [notificationJson(id: 1, title: 'Kept')]),
|
(server) => server.reply(
|
||||||
queryParameters: {'is_new': '', 'page_offset': 0, 'page_limit': 6},
|
200,
|
||||||
|
pagedListJson([notificationJson(id: 1, title: 'Kept')]),
|
||||||
|
),
|
||||||
|
queryParameters: {'is_new': '', 'page_offset': 0, 'page_limit': 5},
|
||||||
);
|
);
|
||||||
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
|
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
|
||||||
|
|
||||||
@@ -336,16 +349,21 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) =>
|
(server) => server.reply(
|
||||||
server.reply(200, [notificationJson(id: 1, title: 'New one')]),
|
200,
|
||||||
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
|
pagedListJson([notificationJson(id: 1, title: 'New one')]),
|
||||||
|
),
|
||||||
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
||||||
);
|
);
|
||||||
adapter.onGet(
|
adapter.onGet(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
(server) => server.reply(200, [
|
(server) => server.reply(
|
||||||
|
200,
|
||||||
|
pagedListJson([
|
||||||
notificationJson(id: 2, title: 'Old one', isNew: false),
|
notificationJson(id: 2, title: 'Old one', isNew: false),
|
||||||
]),
|
]),
|
||||||
queryParameters: {'is_new': false, 'page_offset': 0, 'page_limit': 6},
|
),
|
||||||
|
queryParameters: {'is_new': false, 'page_offset': 0, 'page_limit': 5},
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -365,4 +383,81 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the last-page button jumps to the final page', (tester) async {
|
||||||
|
// Wide default surface → page size 5. A total of 12 spans three pages.
|
||||||
|
List<Map<String, dynamic>> page(int firstId, int count) => List.generate(
|
||||||
|
count,
|
||||||
|
(i) => notificationJson(id: firstId + i, title: 'Item ${firstId + i}'),
|
||||||
|
);
|
||||||
|
adapter.onGet(
|
||||||
|
'/notifications',
|
||||||
|
(server) => server.reply(200, pagedListJson(page(1, 5), totalCount: 12)),
|
||||||
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
||||||
|
);
|
||||||
|
adapter.onGet(
|
||||||
|
'/notifications',
|
||||||
|
(server) => server.reply(200, pagedListJson(page(11, 2), totalCount: 12)),
|
||||||
|
queryParameters: {'is_new': true, 'page_offset': 10, 'page_limit': 5},
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: gruvboxDarkTheme,
|
||||||
|
home: const Scaffold(body: NotificationsList()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Page 1 of 3'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Last page'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Page 3 of 3'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Item 11'), findsWidgets);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('marking read under "New" decrements the page total', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Wide default surface → page size 5. A total of 11 spans three pages;
|
||||||
|
// dropping one locally should leave ten, i.e. two pages.
|
||||||
|
adapter.onGet(
|
||||||
|
'/notifications',
|
||||||
|
(server) => server.reply(
|
||||||
|
200,
|
||||||
|
pagedListJson(
|
||||||
|
List.generate(
|
||||||
|
5,
|
||||||
|
(i) => notificationJson(id: i + 1, title: 'Item ${i + 1}'),
|
||||||
|
),
|
||||||
|
totalCount: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
|
||||||
|
);
|
||||||
|
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: gruvboxDarkTheme,
|
||||||
|
home: const Scaffold(body: NotificationsList()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Page 1 of 3'), findsOneWidget);
|
||||||
|
|
||||||
|
// Mark the first item read; under "New" it leaves the list and the total
|
||||||
|
// drops by one without a re-fetch.
|
||||||
|
await tester.tap(find.byType(ListTile).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Mark as read'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Page 1 of 2'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frontend/theme/dimens.dart';
|
||||||
import 'package:frontend/theme/gruvbox_theme.dart';
|
import 'package:frontend/theme/gruvbox_theme.dart';
|
||||||
import 'package:frontend/widgets/pagination_bar.dart';
|
import 'package:frontend/widgets/pagination_bar.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
Widget wrap(Widget child) => MaterialApp(
|
Widget wrap(Widget child, {Size? size}) => MediaQuery(
|
||||||
|
data: MediaQueryData(size: size ?? const Size(1200, 800)),
|
||||||
|
child: MaterialApp(
|
||||||
theme: gruvboxDarkTheme,
|
theme: gruvboxDarkTheme,
|
||||||
home: Scaffold(body: child),
|
home: Scaffold(body: child),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('disables every navigation button while loading', (tester) async {
|
testWidgets('disables every navigation button while loading', (tester) async {
|
||||||
@@ -15,7 +19,7 @@ void main() {
|
|||||||
wrap(
|
wrap(
|
||||||
PaginationBar(
|
PaginationBar(
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
hasNextPage: true,
|
totalPages: 3,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
onPageChanged: (page) => changedTo = page,
|
onPageChanged: (page) => changedTo = page,
|
||||||
),
|
),
|
||||||
@@ -25,6 +29,7 @@ void main() {
|
|||||||
await tester.tap(find.byTooltip('Next page'));
|
await tester.tap(find.byTooltip('Next page'));
|
||||||
await tester.tap(find.byTooltip('Previous page'));
|
await tester.tap(find.byTooltip('Previous page'));
|
||||||
await tester.tap(find.byTooltip('First page'));
|
await tester.tap(find.byTooltip('First page'));
|
||||||
|
await tester.tap(find.byTooltip('Last page'));
|
||||||
expect(changedTo, -1);
|
expect(changedTo, -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,7 +39,7 @@ void main() {
|
|||||||
wrap(
|
wrap(
|
||||||
PaginationBar(
|
PaginationBar(
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
hasNextPage: true,
|
totalPages: 3,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
onPageChanged: (page) => changedTo = page,
|
onPageChanged: (page) => changedTo = page,
|
||||||
),
|
),
|
||||||
@@ -47,4 +52,73 @@ void main() {
|
|||||||
await tester.tap(find.byTooltip('Previous page'));
|
await tester.tap(find.byTooltip('Previous page'));
|
||||||
expect(changedTo, 0);
|
expect(changedTo, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('last-page button jumps to the final page', (tester) async {
|
||||||
|
var changedTo = -1;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
PaginationBar(
|
||||||
|
currentPage: 0,
|
||||||
|
totalPages: 5,
|
||||||
|
isLoading: false,
|
||||||
|
onPageChanged: (page) => changedTo = page,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Last page'));
|
||||||
|
expect(changedTo, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('forward buttons disable on the last page', (tester) async {
|
||||||
|
var changedTo = -1;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
PaginationBar(
|
||||||
|
currentPage: 4,
|
||||||
|
totalPages: 5,
|
||||||
|
isLoading: false,
|
||||||
|
onPageChanged: (page) => changedTo = page,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Next page'));
|
||||||
|
await tester.tap(find.byTooltip('Last page'));
|
||||||
|
expect(changedTo, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('spells out the page label on wide layouts', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
const PaginationBar(
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 5,
|
||||||
|
isLoading: false,
|
||||||
|
onPageChanged: _noop,
|
||||||
|
),
|
||||||
|
size: const Size(1200, 800),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Page 2 of 5'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('uses the compact page label on narrow layouts', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
const PaginationBar(
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 5,
|
||||||
|
isLoading: false,
|
||||||
|
onPageChanged: _noop,
|
||||||
|
),
|
||||||
|
size: Size(Breakpoints.medium - 1, 800),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('2 / 5'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _noop(int _) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user