Skip to main content

orbital_paging/
lib.rs

1//! Shared paging primitives for Orbital apps.
2//!
3//! This crate provides [`Page`] and [`PageRequest`] — the canonical wire types
4//! for offset/limit pagination across all [`leptos`] server functions. Because it
5//! has no UI or framework dependencies, any crate in the workspace can depend
6//! on it without introducing cycles.
7//!
8//! When the **`leptos`** feature is enabled, this crate also provides
9//! [`use_paged_infinite_scroll`] — a reusable hook that encapsulates the
10//! 4-signal + `use_infinite_scroll` callback pattern for paginated lists.
11//!
12//! # Example
13//!
14//! ```rust
15//! use orbital_paging::{Page, PageRequest};
16//!
17//! let request = PageRequest::new(0, 10);
18//! assert_eq!(request.offset, 0);
19//! assert_eq!(request.limit, 10);
20//!
21//! let page: Page<String> = Page {
22//!     items: vec!["a".into(), "b".into()],
23//!     has_more: false,
24//!     total_count: Some(2),
25//!     next_request_offset: None,
26//! };
27//! assert!(!page.has_more);
28//! ```
29
30use orbital_data::DataValue;
31use serde::{Deserialize, Serialize};
32
33#[cfg(feature = "leptos")]
34mod infinite_scroll;
35#[cfg(feature = "leptos")]
36pub use infinite_scroll::*;
37
38/// Sort direction on the wire.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum SortDirectionWire {
42    Asc,
43    Desc,
44}
45
46/// Single sort column on the wire.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct SortParam {
49    pub field: String,
50    pub direction: SortDirectionWire,
51}
52
53/// How multiple filter rules combine on the wire.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum FilterLogicWire {
57    #[default]
58    And,
59    Or,
60}
61
62/// Single filter rule on the wire.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct FilterRuleParam {
65    pub field: String,
66    pub operator: String,
67    pub value: DataValue,
68}
69
70/// Structured filter query on the wire.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct FilterQuery {
73    pub items: Vec<FilterRuleParam>,
74    pub logic: FilterLogicWire,
75}
76
77/// Client-to-server pagination and query parameters.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct PageRequest {
80    /// Number of items to skip.
81    pub offset: u32,
82    /// Maximum number of items to return.
83    pub limit: u32,
84    /// Optional multi-column sort (server-side).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub sort: Option<Vec<SortParam>>,
87    /// Optional structured filter (server-side).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub filter: Option<FilterQuery>,
90    /// Optional quick-search tokens (server-side).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub quick_search: Option<String>,
93}
94
95impl PageRequest {
96    /// Create a new page request with offset and limit only.
97    pub fn new(offset: u32, limit: u32) -> Self {
98        Self {
99            offset,
100            limit,
101            sort: None,
102            filter: None,
103            quick_search: None,
104        }
105    }
106
107    /// Attach sort, filter, and quick-search query parameters.
108    pub fn with_query(
109        offset: u32,
110        limit: u32,
111        sort: Option<Vec<SortParam>>,
112        filter: Option<FilterQuery>,
113        quick_search: Option<String>,
114    ) -> Self {
115        Self {
116            offset,
117            limit,
118            sort,
119            filter,
120            quick_search,
121        }
122    }
123
124    /// Convenience: is this the first page?
125    pub fn is_first_page(&self) -> bool {
126        self.offset == 0
127    }
128}
129
130/// A single page of results returned by a server function.
131///
132/// `T` is the item type (e.g. `NotificationDto`). The struct derives
133/// `Serialize` and `Deserialize` so it crosses the Leptos server boundary
134/// transparently.
135///
136/// * `has_more` — `true` when more items exist beyond this page.
137/// * `total_count` — optionally present on the **first page** (`offset == 0`)
138///   to give the client the full result-set size without a separate call.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Page<T> {
141    /// The items in this page.
142    pub items: Vec<T>,
143    /// Whether additional pages exist after this one.
144    pub has_more: bool,
145    /// Total number of matching items (typically provided only on the first
146    /// page to avoid repeated count queries).
147    pub total_count: Option<u64>,
148    /// When set, the next `(offset, limit)` fetch should use **this** value as
149    /// `offset` instead of `accumulated_items.len()`.
150    ///
151    /// Use when the server applies `offset`/`limit` at the **database row**
152    /// layer but returns **fewer** items after filtering (e.g. resolving a
153    /// join skips some rows). Without this, the client would skip the wrong
154    /// slice and can show a short first page plus a premature “end of list”.
155    #[serde(default)]
156    pub next_request_offset: Option<u32>,
157}
158
159impl<T> Page<T> {
160    /// Build a `Page` from a result set fetched with `limit + 1` rows.
161    ///
162    /// If `raw_items` contains more than `limit` entries the extra row is
163    /// removed and `has_more` is set to `true`.
164    pub fn from_oversized(mut raw_items: Vec<T>, limit: u32, total_count: Option<u64>) -> Self {
165        let has_more = raw_items.len() as u32 > limit;
166        if has_more {
167            raw_items.truncate(limit as usize);
168        }
169        Self {
170            items: raw_items,
171            has_more,
172            total_count,
173            next_request_offset: None,
174        }
175    }
176
177    /// Create an empty page (no items, no more pages).
178    pub fn empty() -> Self {
179        Self {
180            items: Vec::new(),
181            has_more: false,
182            total_count: Some(0),
183            next_request_offset: None,
184        }
185    }
186
187    /// Number of items in this page.
188    pub fn len(&self) -> usize {
189        self.items.len()
190    }
191
192    /// Whether this page contains zero items.
193    pub fn is_empty(&self) -> bool {
194        self.items.is_empty()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn from_oversized_trims_and_sets_has_more() {
204        let items: Vec<u32> = (1..=11).collect(); // 11 items, limit 10
205        let page = Page::from_oversized(items, 10, Some(25));
206        assert_eq!(page.items.len(), 10);
207        assert!(page.has_more);
208        assert_eq!(page.total_count, Some(25));
209    }
210
211    #[test]
212    fn from_oversized_no_extra_item() {
213        let items: Vec<u32> = (1..=10).collect(); // exactly 10
214        let page = Page::from_oversized(items, 10, None);
215        assert_eq!(page.items.len(), 10);
216        assert!(!page.has_more);
217        assert_eq!(page.total_count, None);
218    }
219
220    #[test]
221    fn page_request_first_page() {
222        assert!(PageRequest::new(0, 10).is_first_page());
223        assert!(!PageRequest::new(10, 10).is_first_page());
224    }
225
226    #[test]
227    fn page_request_legacy_deserialize() {
228        let json = r#"{"offset":0,"limit":10}"#;
229        let req: PageRequest = serde_json::from_str(json).unwrap();
230        assert_eq!(req.offset, 0);
231        assert_eq!(req.limit, 10);
232        assert!(req.sort.is_none());
233        assert!(req.filter.is_none());
234        assert!(req.quick_search.is_none());
235    }
236
237    #[test]
238    fn empty_page() {
239        let page: Page<String> = Page::empty();
240        assert!(page.is_empty());
241        assert!(!page.has_more);
242        assert_eq!(page.total_count, Some(0));
243    }
244}