Add pagination to the devices list screen

Mirror the notifications list paging pattern (offset/limit, fetch one
extra row to detect the next page, First/Prev/Next controls) across the
devices DB query, REST endpoint, API client and UI. Also add a First
page button to the notifications pagination row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-29 13:07:27 -04:00
co-authored by Claude Opus 4.7
parent 75f597bfe4
commit 251c06277e
6 changed files with 148 additions and 21 deletions
+2 -2
View File
@@ -17,9 +17,9 @@
## Frontend ## Frontend
- [ ] Add a "go to first" and "go to last" buttons to paginations (notification list for now) - [x] Add a "go to first" and "go to last" buttons to paginations (notification list for now)
- [x] Replace the ARP scanner widget in the home screen with a Scanning status widget that provides a one liner for each scanner - [x] Replace the ARP scanner widget in the home screen with a Scanning status widget that provides a one liner for each scanner
- [ ] Add pagination to the devices list screen - now you can't see all devices - [x] Add pagination to the devices list screen - now you can't see all devices
- [x] Add the mDNS/Bonjour scanner status to the Status and Home screens - [x] Add the mDNS/Bonjour scanner status to the Status and Home screens
- [x] Change the notifications list so it has explicit paging (not infinite paging) - [x] Change the notifications list so it has explicit paging (not infinite paging)
- [x] List recorded devices - [x] List recorded devices
+50 -3
View File
@@ -7,6 +7,7 @@ use crate::{
model::devices::{Device, DeviceSummary}, model::devices::{Device, DeviceSummary},
}; };
#[allow(clippy::too_many_arguments)]
pub fn list_devices( pub fn list_devices(
is_registered: Option<bool>, is_registered: Option<bool>,
last_seen_from: Option<DateTime<Utc>>, last_seen_from: Option<DateTime<Utc>>,
@@ -14,6 +15,8 @@ pub fn list_devices(
owner: Option<String>, owner: Option<String>,
device_type: Option<String>, device_type: Option<String>,
vendor: Option<String>, vendor: Option<String>,
page_offset: Option<i64>,
page_limit: Option<i64>,
) -> Result<Vec<Device>, DbError> { ) -> Result<Vec<Device>, DbError> {
debug!("Listing devices"); debug!("Listing devices");
let conn = db::get_db_connection(); let conn = db::get_db_connection();
@@ -52,6 +55,21 @@ pub fn list_devices(
params.push(vendor.into()); params.push(vendor.into());
} }
// List order
sql_statement.push_str("ORDER BY last_seen DESC ");
// Paging
if let (Some(page_offset), Some(page_limit)) = (page_offset, page_limit) {
debug!(
"Adding paging to list with offset={} and limit={}",
page_offset, page_limit
);
sql_statement.push_str("LIMIT ? OFFSET ?");
params.push(page_limit.into());
params.push(page_offset.into());
};
let mut stmt = conn.prepare(sql_statement.as_str())?; let mut stmt = conn.prepare(sql_statement.as_str())?;
let devices: Vec<Device> = stmt let devices: Vec<Device> = stmt
@@ -215,7 +233,8 @@ mod tests {
tests_common::setup().await; tests_common::setup().await;
// List all devices // List all devices
let devices: Vec<Device> = list_devices(None, None, None, None, None, None).unwrap(); let devices: Vec<Device> =
list_devices(None, None, None, None, None, None, None, None).unwrap();
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
@@ -237,7 +256,8 @@ mod tests {
); );
// List registered devices // List registered devices
let devices: Vec<Device> = list_devices(Some(true), None, None, None, None, None).unwrap(); let devices: Vec<Device> =
list_devices(Some(true), None, None, None, None, None, None, None).unwrap();
assert!( assert!(
devices.len() >= 2, devices.len() >= 2,
@@ -283,7 +303,7 @@ mod tests {
// Filter by owner substring - "oh" matches "John" but not "Sarah" // Filter by owner substring - "oh" matches "John" but not "Sarah"
let devices: Vec<Device> = let devices: Vec<Device> =
list_devices(None, None, None, Some("oh".to_string()), None, None).unwrap(); list_devices(None, None, None, Some("oh".to_string()), None, None, None, None).unwrap();
assert!( assert!(
devices.len() >= 1, devices.len() >= 1,
@@ -310,6 +330,8 @@ mod tests {
None, None,
None, None,
None, None,
None,
None,
) )
.unwrap(); .unwrap();
@@ -324,6 +346,31 @@ mod tests {
); );
} }
#[tokio::test]
async fn test_list_pagination() {
tests_common::setup().await;
// First page with 2 devices
let first_page = list_devices(None, None, None, None, None, None, Some(0), Some(2)).unwrap();
assert_eq!(first_page.len(), 2, "First page should have 2 devices");
// Second page with 2 devices, should have at least 1 (seed data has >= 3 devices)
let second_page =
list_devices(None, None, None, None, None, None, Some(2), Some(2)).unwrap();
assert!(
!second_page.is_empty(),
"Second page should have at least 1 device"
);
// Pages should not overlap
assert!(
!first_page
.iter()
.any(|d| second_page.iter().any(|s| s.mac_address == d.mac_address)),
"First and second pages should not share devices"
);
}
#[tokio::test] #[tokio::test]
async fn test_update() { async fn test_update() {
tests_common::setup().await; tests_common::setup().await;
+6
View File
@@ -26,6 +26,8 @@ use crate::web_server::utils;
("owner" = Option<String>, Query, description = "Filter by owner"), ("owner" = Option<String>, Query, description = "Filter by owner"),
("device_type" = Option<String>, Query, description = "Filter by device type"), ("device_type" = Option<String>, Query, description = "Filter by device type"),
("vendor" = Option<String>, Query, description = "Filter by vendor"), ("vendor" = Option<String>, Query, description = "Filter by vendor"),
("page_offset" = Option<i64>, Query, description = "Pagination offset"),
("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 = Vec<Device>),
@@ -43,6 +45,8 @@ pub async fn list(
let owner: Option<String> = utils::parse_parameter_string(&params, "owner"); let owner: Option<String> = utils::parse_parameter_string(&params, "owner");
let device_type: Option<String> = utils::parse_parameter_string(&params, "device_type"); let device_type: Option<String> = utils::parse_parameter_string(&params, "device_type");
let vendor: Option<String> = utils::parse_parameter_string(&params, "vendor"); let vendor: Option<String> = utils::parse_parameter_string(&params, "vendor");
let page_offset: Option<i64> = utils::parse_parameter_int(&params, "page_offset");
let page_limit: Option<i64> = utils::parse_parameter_int(&params, "page_limit");
match db::devices::list_devices( match db::devices::list_devices(
is_registered, is_registered,
@@ -51,6 +55,8 @@ pub async fn list(
owner, owner,
device_type, device_type,
vendor, vendor,
page_offset,
page_limit,
) { ) {
Ok(value) => Ok(Json(value)), Ok(value) => Ok(Json(value)),
Err(err) => { Err(err) => {
+76 -16
View File
@@ -10,6 +10,8 @@ import '../utils/oott_api.dart';
import '../widgets/status_badge.dart'; import '../widgets/status_badge.dart';
import 'device_actions.dart'; import 'device_actions.dart';
const _pageSize = 5;
enum _DeviceFilter { enum _DeviceFilter {
newDevices('Not registered'), newDevices('Not registered'),
registered('Registered'), registered('Registered'),
@@ -36,11 +38,14 @@ class _DeviceListState extends State<DeviceList> {
DeviceType? _typeFilter; DeviceType? _typeFilter;
Timer? _ownerDebounce; Timer? _ownerDebounce;
int _currentPage = 0;
bool _hasNextPage = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_ownerController.addListener(_onOwnerChanged); _ownerController.addListener(_onOwnerChanged);
_loadDevices(); _fetchPage(0);
} }
@override @override
@@ -52,10 +57,13 @@ class _DeviceListState extends State<DeviceList> {
void _onOwnerChanged() { void _onOwnerChanged() {
_ownerDebounce?.cancel(); _ownerDebounce?.cancel();
_ownerDebounce = Timer(const Duration(milliseconds: 500), _loadDevices); _ownerDebounce = Timer(
const Duration(milliseconds: 500),
() => _fetchPage(0),
);
} }
Future<void> _loadDevices() async { Future<void> _fetchPage(int page) async {
setState(() { setState(() {
_isLoading = _devices.isEmpty; _isLoading = _devices.isEmpty;
_error = null; _error = null;
@@ -65,14 +73,18 @@ class _DeviceListState extends State<DeviceList> {
if (_filter == _DeviceFilter.newDevices) isRegistered = false; if (_filter == _DeviceFilter.newDevices) isRegistered = false;
if (_filter == _DeviceFilter.registered) isRegistered = true; if (_filter == _DeviceFilter.registered) isRegistered = true;
final devices = await BackendAPI.instance.listDevices( final results = await BackendAPI.instance.listDevices(
isRegistered: isRegistered, isRegistered: isRegistered,
owner: _ownerController.text.isEmpty ? null : _ownerController.text, owner: _ownerController.text.isEmpty ? null : _ownerController.text,
deviceType: _typeFilter, deviceType: _typeFilter,
offset: page * _pageSize,
limit: _pageSize + 1,
); );
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_devices = devices; _currentPage = page;
_hasNextPage = results.length > _pageSize;
_devices = _hasNextPage ? results.take(_pageSize).toList() : results;
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
@@ -103,14 +115,14 @@ class _DeviceListState extends State<DeviceList> {
hasActiveFilters: _hasActiveDetailFilters, hasActiveFilters: _hasActiveDetailFilters,
onTypeChanged: (value) { onTypeChanged: (value) {
setState(() => _typeFilter = value); setState(() => _typeFilter = value);
_loadDevices(); _fetchPage(0);
}, },
onClear: () { onClear: () {
setState(() { setState(() {
_ownerController.clear(); _ownerController.clear();
_typeFilter = null; _typeFilter = null;
}); });
_loadDevices(); _fetchPage(0);
}, },
), ),
); );
@@ -149,7 +161,7 @@ class _DeviceListState extends State<DeviceList> {
selected: _filter == f, selected: _filter == f,
onSelected: (_) { onSelected: (_) {
setState(() => _filter = f); setState(() => _filter = f);
_loadDevices(); _fetchPage(0);
}, },
), ),
) )
@@ -175,21 +187,69 @@ class _DeviceListState extends State<DeviceList> {
else else
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: _loadDevices, onRefresh: () => _fetchPage(_currentPage),
child: ListView.builder( child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: _devices.length, slivers: [
itemBuilder: (context, index) => _DeviceCard( SliverList.builder(
device: _devices[index], itemCount: _devices.length,
formatter: formatter, itemBuilder: (context, index) => _DeviceCard(
onRefresh: _loadDevices, device: _devices[index],
), formatter: formatter,
onRefresh: () => _fetchPage(_currentPage),
),
),
if (_currentPage > 0 || _hasNextPage)
_buildPaginationControls(context),
],
), ),
), ),
), ),
], ],
); );
} }
Widget _buildPaginationControls(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.outlined(
onPressed: _currentPage > 0 && !_isLoading
? () => _fetchPage(0)
: null,
icon: const Icon(Icons.first_page),
tooltip: 'First page',
),
const SizedBox(width: 8),
IconButton.outlined(
onPressed: _currentPage > 0 && !_isLoading
? () => _fetchPage(_currentPage - 1)
: null,
icon: const Icon(Icons.chevron_left),
tooltip: 'Previous page',
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Page ${_currentPage + 1}',
style: Theme.of(context).textTheme.bodyMedium,
),
),
IconButton.outlined(
onPressed: _hasNextPage && !_isLoading
? () => _fetchPage(_currentPage + 1)
: null,
icon: const Icon(Icons.chevron_right),
tooltip: 'Next page',
),
],
),
),
);
}
} }
class _DeviceFilterSheet extends StatelessWidget { class _DeviceFilterSheet extends StatelessWidget {
@@ -234,6 +234,14 @@ class _NotificationsListState extends State<NotificationsList> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
IconButton.outlined(
onPressed: _currentPage > 0 && !_isLoading
? () => _fetchPage(0)
: null,
icon: const Icon(Icons.first_page),
tooltip: 'First page',
),
const SizedBox(width: 8),
IconButton.outlined( IconButton.outlined(
onPressed: _currentPage > 0 && !_isLoading onPressed: _currentPage > 0 && !_isLoading
? () => _fetchPage(_currentPage - 1) ? () => _fetchPage(_currentPage - 1)
+6
View File
@@ -99,6 +99,8 @@ class BackendAPI {
bool? isRegistered, bool? isRegistered,
String? owner, String? owner,
DeviceType? deviceType, DeviceType? deviceType,
int? offset,
int? limit,
}) async { }) async {
debugPrint('About to call /devices'); debugPrint('About to call /devices');
@@ -110,6 +112,10 @@ class BackendAPI {
? '' ? ''
: deviceType.apiName; : deviceType.apiName;
} }
if (offset != null) {
params['page_offset'] = offset;
params['page_limit'] = limit ?? _pageSize;
}
final response = await _dio.get('/devices', queryParameters: params); final response = await _dio.get('/devices', queryParameters: params);