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:
rzuasti
2026-06-05 21:09:25 -04:00
co-authored by Claude Opus 4.8
parent 3cb104e364
commit 844d7d189c
21 changed files with 765 additions and 275 deletions
+47 -45
View File
@@ -35,60 +35,62 @@ void main() {
expect(summary.totalRegistered, 7);
});
test('listDevices sends filter + pagination params and trims extra item',
() async {
adapter.onGet(
'/devices',
(server) => server.reply(200, [
deviceJson(macAddress: '00:00:00:00:00:01'),
deviceJson(macAddress: '00:00:00:00:00:02'),
deviceJson(macAddress: '00:00:00:00:00:03'),
]),
queryParameters: {
'is_registered': false,
'device_type': 'laptop',
'sort_by': 'last_seen',
'sort_order': 'asc',
'page_offset': 0,
'page_limit': 3,
},
);
test(
'listDevices sends filter + pagination params and reports the total',
() async {
adapter.onGet(
'/devices',
(server) => server.reply(
200,
pagedListJson([
deviceJson(macAddress: '00:00:00:00:00:01'),
deviceJson(macAddress: '00:00:00:00:00:02'),
], totalCount: 12),
),
queryParameters: {
'is_registered': false,
'device_type': 'laptop',
'sort_by': 'last_seen',
'sort_order': 'asc',
'page_offset': 0,
'page_limit': 2,
},
);
final result = await BackendAPI.instance.listDevices(
isRegistered: false,
deviceType: DeviceType.laptop,
sortBy: 'last_seen',
sortAscending: true,
page: 0,
perPage: 2,
);
final result = await BackendAPI.instance.listDevices(
isRegistered: false,
deviceType: DeviceType.laptop,
sortBy: 'last_seen',
sortAscending: true,
page: 0,
perPage: 2,
);
expect(result.items, hasLength(2));
expect(result.hasNextPage, isTrue);
});
expect(result.items, hasLength(2));
expect(result.totalCount, 12);
},
);
test('listDevices reports no next page when fewer than perPage+1 returned',
() async {
adapter.onGet(
'/devices',
(server) => server.reply(200, [deviceJson()]),
);
test(
'listDevices reports the total when a single page is returned',
() async {
adapter.onGet(
'/devices',
(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.hasNextPage, isFalse);
});
expect(result.items, hasLength(1));
expect(result.totalCount, 1);
},
);
test('listDevices maps the unknown device type to an empty filter', () async {
adapter.onGet(
'/devices',
(server) => server.reply(200, <dynamic>[]),
queryParameters: {
'device_type': '',
'page_offset': 0,
'page_limit': 11,
},
(server) => server.reply(200, pagedListJson([])),
queryParameters: {'device_type': '', 'page_offset': 0, 'page_limit': 10},
);
final result = await BackendAPI.instance.listDevices(
+41 -46
View File
@@ -13,10 +13,7 @@ void main() {
});
test('markNotificationAsRead GETs the notification path', () async {
adapter.onGet(
'/notifications/7',
(server) => server.reply(200, null),
);
adapter.onGet('/notifications/7', (server) => server.reply(200, null));
await BackendAPI.instance.markNotificationAsRead(7);
});
@@ -39,51 +36,49 @@ void main() {
await BackendAPI.instance.markAllNotificationsAsRead();
});
test('listNotifications sends is_new + pagination and trims extra item',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [
notificationJson(id: 1),
notificationJson(id: 2),
notificationJson(id: 3),
]),
queryParameters: {
'is_new': true,
'page_offset': 0,
'page_limit': 3,
},
);
test(
'listNotifications sends is_new + pagination and reports the total',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(
200,
pagedListJson([
notificationJson(id: 1),
notificationJson(id: 2),
], totalCount: 9),
),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 2},
);
final result = await BackendAPI.instance.listNotifications(
true,
page: 0,
perPage: 2,
);
final result = await BackendAPI.instance.listNotifications(
true,
page: 0,
perPage: 2,
);
expect(result.items, hasLength(2));
expect(result.hasNextPage, isTrue);
});
expect(result.items, hasLength(2));
expect(result.totalCount, 9);
},
);
test('listNotifications maps a null filter to an empty is_new param',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, <dynamic>[]),
queryParameters: {
'is_new': '',
'page_offset': 10,
'page_limit': 6,
},
);
test(
'listNotifications maps a null filter to an empty is_new param',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, pagedListJson([])),
queryParameters: {'is_new': '', 'page_offset': 10, 'page_limit': 5},
);
final result = await BackendAPI.instance.listNotifications(
null,
page: 2,
perPage: 5,
);
final result = await BackendAPI.instance.listNotifications(
null,
page: 2,
perPage: 5,
);
expect(result.items, isEmpty);
expect(result.hasNextPage, isFalse);
});
expect(result.items, isEmpty);
expect(result.totalCount, 0);
},
);
}