From 251c06277e1c66bde72822cfa1bf047c6f1ad24a Mon Sep 17 00:00:00 2001 From: rzuasti Date: Fri, 29 May 2026 13:07:27 -0400 Subject: [PATCH] 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 --- TODO.md | 4 +- backend/src/db/devices.rs | 53 ++++++++++++- backend/src/web_server/devices.rs | 6 ++ frontend/lib/devices/device_list.dart | 92 +++++++++++++++++++---- frontend/lib/home/notifications_list.dart | 8 ++ frontend/lib/utils/oott_api.dart | 6 ++ 6 files changed, 148 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index 21d6eba..64681ea 100644 --- a/TODO.md +++ b/TODO.md @@ -17,9 +17,9 @@ ## 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 -- [ ] 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] Change the notifications list so it has explicit paging (not infinite paging) - [x] List recorded devices diff --git a/backend/src/db/devices.rs b/backend/src/db/devices.rs index e8d7298..6d021e1 100644 --- a/backend/src/db/devices.rs +++ b/backend/src/db/devices.rs @@ -7,6 +7,7 @@ use crate::{ model::devices::{Device, DeviceSummary}, }; +#[allow(clippy::too_many_arguments)] pub fn list_devices( is_registered: Option, last_seen_from: Option>, @@ -14,6 +15,8 @@ pub fn list_devices( owner: Option, device_type: Option, vendor: Option, + page_offset: Option, + page_limit: Option, ) -> Result, DbError> { debug!("Listing devices"); let conn = db::get_db_connection(); @@ -52,6 +55,21 @@ pub fn list_devices( 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 devices: Vec = stmt @@ -215,7 +233,8 @@ mod tests { tests_common::setup().await; // List all devices - let devices: Vec = list_devices(None, None, None, None, None, None).unwrap(); + let devices: Vec = + list_devices(None, None, None, None, None, None, None, None).unwrap(); assert!(devices.len() >= 3, "There should be at least 3 devices"); // Validate 1 device data @@ -237,7 +256,8 @@ mod tests { ); // List registered devices - let devices: Vec = list_devices(Some(true), None, None, None, None, None).unwrap(); + let devices: Vec = + list_devices(Some(true), None, None, None, None, None, None, None).unwrap(); assert!( devices.len() >= 2, @@ -283,7 +303,7 @@ mod tests { // Filter by owner substring - "oh" matches "John" but not "Sarah" let devices: Vec = - 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!( devices.len() >= 1, @@ -310,6 +330,8 @@ mod tests { None, None, None, + None, + None, ) .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] async fn test_update() { tests_common::setup().await; diff --git a/backend/src/web_server/devices.rs b/backend/src/web_server/devices.rs index 218a6d8..712f975 100644 --- a/backend/src/web_server/devices.rs +++ b/backend/src/web_server/devices.rs @@ -26,6 +26,8 @@ use crate::web_server::utils; ("owner" = Option, Query, description = "Filter by owner"), ("device_type" = Option, Query, description = "Filter by device type"), ("vendor" = Option, Query, description = "Filter by vendor"), + ("page_offset" = Option, Query, description = "Pagination offset"), + ("page_limit" = Option, Query, description = "Maximum number of results to return"), ), responses( (status = 200, description = "List of devices", body = Vec), @@ -43,6 +45,8 @@ pub async fn list( let owner: Option = utils::parse_parameter_string(¶ms, "owner"); let device_type: Option = utils::parse_parameter_string(¶ms, "device_type"); let vendor: Option = utils::parse_parameter_string(¶ms, "vendor"); + let page_offset: Option = utils::parse_parameter_int(¶ms, "page_offset"); + let page_limit: Option = utils::parse_parameter_int(¶ms, "page_limit"); match db::devices::list_devices( is_registered, @@ -51,6 +55,8 @@ pub async fn list( owner, device_type, vendor, + page_offset, + page_limit, ) { Ok(value) => Ok(Json(value)), Err(err) => { diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index 465d97d..6d6ed86 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -10,6 +10,8 @@ import '../utils/oott_api.dart'; import '../widgets/status_badge.dart'; import 'device_actions.dart'; +const _pageSize = 5; + enum _DeviceFilter { newDevices('Not registered'), registered('Registered'), @@ -36,11 +38,14 @@ class _DeviceListState extends State { DeviceType? _typeFilter; Timer? _ownerDebounce; + int _currentPage = 0; + bool _hasNextPage = false; + @override void initState() { super.initState(); _ownerController.addListener(_onOwnerChanged); - _loadDevices(); + _fetchPage(0); } @override @@ -52,10 +57,13 @@ class _DeviceListState extends State { void _onOwnerChanged() { _ownerDebounce?.cancel(); - _ownerDebounce = Timer(const Duration(milliseconds: 500), _loadDevices); + _ownerDebounce = Timer( + const Duration(milliseconds: 500), + () => _fetchPage(0), + ); } - Future _loadDevices() async { + Future _fetchPage(int page) async { setState(() { _isLoading = _devices.isEmpty; _error = null; @@ -65,14 +73,18 @@ class _DeviceListState extends State { if (_filter == _DeviceFilter.newDevices) isRegistered = false; if (_filter == _DeviceFilter.registered) isRegistered = true; - final devices = await BackendAPI.instance.listDevices( + final results = await BackendAPI.instance.listDevices( isRegistered: isRegistered, owner: _ownerController.text.isEmpty ? null : _ownerController.text, deviceType: _typeFilter, + offset: page * _pageSize, + limit: _pageSize + 1, ); if (!mounted) return; setState(() { - _devices = devices; + _currentPage = page; + _hasNextPage = results.length > _pageSize; + _devices = _hasNextPage ? results.take(_pageSize).toList() : results; _isLoading = false; }); } catch (e) { @@ -103,14 +115,14 @@ class _DeviceListState extends State { hasActiveFilters: _hasActiveDetailFilters, onTypeChanged: (value) { setState(() => _typeFilter = value); - _loadDevices(); + _fetchPage(0); }, onClear: () { setState(() { _ownerController.clear(); _typeFilter = null; }); - _loadDevices(); + _fetchPage(0); }, ), ); @@ -149,7 +161,7 @@ class _DeviceListState extends State { selected: _filter == f, onSelected: (_) { setState(() => _filter = f); - _loadDevices(); + _fetchPage(0); }, ), ) @@ -175,21 +187,69 @@ class _DeviceListState extends State { else Expanded( child: RefreshIndicator( - onRefresh: _loadDevices, - child: ListView.builder( + onRefresh: () => _fetchPage(_currentPage), + child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), - itemCount: _devices.length, - itemBuilder: (context, index) => _DeviceCard( - device: _devices[index], - formatter: formatter, - onRefresh: _loadDevices, - ), + slivers: [ + SliverList.builder( + itemCount: _devices.length, + itemBuilder: (context, index) => _DeviceCard( + 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 { diff --git a/frontend/lib/home/notifications_list.dart b/frontend/lib/home/notifications_list.dart index 2db1f24..c4ae3e3 100644 --- a/frontend/lib/home/notifications_list.dart +++ b/frontend/lib/home/notifications_list.dart @@ -234,6 +234,14 @@ class _NotificationsListState extends State { 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) diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index feaf807..2ee2b09 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -99,6 +99,8 @@ class BackendAPI { bool? isRegistered, String? owner, DeviceType? deviceType, + int? offset, + int? limit, }) async { debugPrint('About to call /devices'); @@ -110,6 +112,10 @@ class BackendAPI { ? '' : deviceType.apiName; } + if (offset != null) { + params['page_offset'] = offset; + params['page_limit'] = limit ?? _pageSize; + } final response = await _dio.get('/devices', queryParameters: params);