@utiliread/primitives 1.0.0-143 → 1.0.0-144
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cursor-navigation-state.d.ts +61 -0
- package/dist/cursor-navigation-state.spec.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cursor-navigation-state.spec.ts +194 -0
- package/src/cursor-navigation-state.ts +168 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
interface CursorPage {
|
|
2
|
+
items: unknown[];
|
|
3
|
+
nextCursor: string | null;
|
|
4
|
+
hasMore: boolean;
|
|
5
|
+
totalCount?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tracks client-side navigation state for cursor-based paging.
|
|
10
|
+
*
|
|
11
|
+
* The first page is represented by the empty-string cursor.
|
|
12
|
+
* `futureCursor` is the server-provided cursor for the page after the current
|
|
13
|
+
* one, while `cursors` keeps the visited cursor chain so the UI can move
|
|
14
|
+
* backward without recomputing anything.
|
|
15
|
+
*/
|
|
16
|
+
export class CursorNavigationState {
|
|
17
|
+
/** Number of items requested per page. */
|
|
18
|
+
readonly limit: number;
|
|
19
|
+
|
|
20
|
+
/** Cursor for the currently displayed page. */
|
|
21
|
+
currentCursor: string = "";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cursor for the next page as reported by the server.
|
|
25
|
+
*
|
|
26
|
+
* `undefined` means the current page has not been loaded yet.
|
|
27
|
+
* `null` means that all pages are exhausted.
|
|
28
|
+
*/
|
|
29
|
+
futureCursor: string | null | undefined = undefined;
|
|
30
|
+
|
|
31
|
+
/** Visited page cursor chain in navigation order. */
|
|
32
|
+
private cursors: string[] = [];
|
|
33
|
+
private pageItemCounts = new Map<string, number>();
|
|
34
|
+
|
|
35
|
+
/** Total item count when the API provides it or when the last page is reached. */
|
|
36
|
+
totalCount: number | null = null;
|
|
37
|
+
|
|
38
|
+
/** One-based page number inferred from the visited cursor chain. */
|
|
39
|
+
get pageNumber() {
|
|
40
|
+
return this.cursors.indexOf(this.currentCursor) + 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Total number of pages computed from the total item count and page limit. */
|
|
44
|
+
get pageCount() {
|
|
45
|
+
return this.totalCount != null
|
|
46
|
+
? Math.ceil(this.totalCount / this.limit)
|
|
47
|
+
: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One-based index of the first item on the current page. */
|
|
51
|
+
get firstItemNumber() {
|
|
52
|
+
const index = this.cursors.indexOf(this.currentCursor);
|
|
53
|
+
const currentPageItemCount =
|
|
54
|
+
this.pageItemCounts.get(this.currentCursor) ?? 0;
|
|
55
|
+
return index >= 0 && currentPageItemCount > 0
|
|
56
|
+
? this.cursors
|
|
57
|
+
.slice(0, index)
|
|
58
|
+
.reduce(
|
|
59
|
+
(itemCount, cursor) =>
|
|
60
|
+
itemCount + (this.pageItemCounts.get(cursor) ?? 0),
|
|
61
|
+
0,
|
|
62
|
+
) + 1
|
|
63
|
+
: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One-based index of the last item on the current page. */
|
|
67
|
+
get lastItemNumber() {
|
|
68
|
+
const currentPageItemCount =
|
|
69
|
+
this.pageItemCounts.get(this.currentCursor) ?? 0;
|
|
70
|
+
return currentPageItemCount > 0
|
|
71
|
+
? this.firstItemNumber + currentPageItemCount - 1
|
|
72
|
+
: 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Cursor for the next page, or the current page cursor if the current page is the last page.
|
|
77
|
+
*/
|
|
78
|
+
get nextCursor() {
|
|
79
|
+
const currentIndex = this.cursors.indexOf(this.currentCursor);
|
|
80
|
+
return currentIndex >= 0 && currentIndex < this.cursors.length - 1
|
|
81
|
+
? this.cursors[currentIndex + 1]
|
|
82
|
+
: (this.futureCursor ?? this.currentCursor);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Cursor for the previous page, or the first-page sentinel. */
|
|
86
|
+
get previousCursor() {
|
|
87
|
+
const currentIndex = this.cursors.indexOf(this.currentCursor);
|
|
88
|
+
const previousCursor = this.cursors[currentIndex - 1] ?? "";
|
|
89
|
+
return previousCursor;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether the current page is the first page. */
|
|
93
|
+
get currentIsFirst() {
|
|
94
|
+
return this.currentCursor === "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether the current page is the last page. */
|
|
98
|
+
get currentIsLast() {
|
|
99
|
+
return (
|
|
100
|
+
this.futureCursor === null &&
|
|
101
|
+
(this.cursors.length === 0 ||
|
|
102
|
+
this.cursors[this.cursors.length - 1] === this.currentCursor)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
constructor(options?: { limit?: number }) {
|
|
107
|
+
this.limit = options?.limit ?? 20;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Applies a fetched page to the navigation state.
|
|
112
|
+
*
|
|
113
|
+
* `cursor` is the request cursor that produced `page`.
|
|
114
|
+
*/
|
|
115
|
+
visitPage(cursor: string, page: CursorPage) {
|
|
116
|
+
// Determine if this page is the first in the navigation flow, and that it is the first time it it visited.
|
|
117
|
+
const isInitialFirstPage = this.cursors.length === 0 && cursor === "";
|
|
118
|
+
const isExpectedFuturePage =
|
|
119
|
+
this.cursors.length > 0 && cursor === this.futureCursor;
|
|
120
|
+
|
|
121
|
+
this.currentCursor = cursor;
|
|
122
|
+
|
|
123
|
+
// Add the visited cursor to the chain only if it's the first or expected future page.
|
|
124
|
+
if (isInitialFirstPage || isExpectedFuturePage) {
|
|
125
|
+
this.cursors.push(cursor);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Set or update the item count for the visited page.
|
|
129
|
+
if (this.cursors.includes(cursor)) {
|
|
130
|
+
this.pageItemCounts.set(cursor, page.items.length);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Set or advance the future cursor if monotonically advancing.
|
|
134
|
+
// Note that `futureCursor` is advanced even when `cursors.length === 0`.
|
|
135
|
+
// This allows us to "start" with some intermediate cursor if the first page is not the initial page;
|
|
136
|
+
// in that case we still track the `futureCursor` so that we can navigate forward - backward in this case is however not possible.
|
|
137
|
+
if (
|
|
138
|
+
this.futureCursor === undefined ||
|
|
139
|
+
isInitialFirstPage ||
|
|
140
|
+
cursor === this.futureCursor
|
|
141
|
+
) {
|
|
142
|
+
this.futureCursor = page.nextCursor;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Assign total count if provided
|
|
146
|
+
if (page.totalCount != null) {
|
|
147
|
+
this.totalCount = page.totalCount;
|
|
148
|
+
} else if (
|
|
149
|
+
(isInitialFirstPage || isExpectedFuturePage) &&
|
|
150
|
+
!page.hasMore
|
|
151
|
+
) {
|
|
152
|
+
this.totalCount = this.cursors.reduce(
|
|
153
|
+
(itemCount, cursor) =>
|
|
154
|
+
itemCount + (this.pageItemCounts.get(cursor) ?? 0),
|
|
155
|
+
0,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
toString() {
|
|
161
|
+
const hasItemNumbers = this.cursors.indexOf(this.currentCursor) >= 0;
|
|
162
|
+
const range = hasItemNumbers
|
|
163
|
+
? `${this.firstItemNumber}-${this.lastItemNumber}`
|
|
164
|
+
: "...";
|
|
165
|
+
const total = this.totalCount ?? "?";
|
|
166
|
+
return `${range} / ${total}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export { RegisterLibrary } from "./register-library";
|
|
|
40
40
|
export { Repository } from "./repository";
|
|
41
41
|
export { TimeOrRule } from "./time-or-rule";
|
|
42
42
|
export type { UnitId } from "./unit-id";
|
|
43
|
+
export { CursorNavigationState } from "./cursor-navigation-state";
|
|
43
44
|
|
|
44
45
|
export const registers: Record<RegisterId, RegisterDefinition> = Registers;
|
|
45
46
|
|