@proteinjs/db-ui 1.8.4 → 1.9.0
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/CHANGELOG.md +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/pages/HashGeneratorPage.d.ts.map +1 -1
- package/dist/src/pages/HashGeneratorPage.js +0 -1
- package/dist/src/pages/HashGeneratorPage.js.map +1 -1
- package/dist/src/pages/RecordFormPage.d.ts.map +1 -1
- package/dist/src/pages/RecordFormPage.js +13 -6
- package/dist/src/pages/RecordFormPage.js.map +1 -1
- package/dist/src/pages/RecordTablePage.d.ts.map +1 -1
- package/dist/src/pages/RecordTablePage.js +9 -6
- package/dist/src/pages/RecordTablePage.js.map +1 -1
- package/dist/src/pages/TablesPage.d.ts.map +1 -1
- package/dist/src/pages/TablesPage.js +0 -1
- package/dist/src/pages/TablesPage.js.map +1 -1
- package/dist/src/pages/UuidGeneratorPage.d.ts.map +1 -1
- package/dist/src/pages/UuidGeneratorPage.js +0 -1
- package/dist/src/pages/UuidGeneratorPage.js.map +1 -1
- package/dist/src/table/QueryCursorLoader.d.ts +10 -14
- package/dist/src/table/QueryCursorLoader.d.ts.map +1 -1
- package/dist/src/table/QueryCursorLoader.js +20 -41
- package/dist/src/table/QueryCursorLoader.js.map +1 -1
- package/dist/test/QueryCursorLoader.test.d.ts +1 -1
- package/dist/test/QueryCursorLoader.test.d.ts.map +1 -1
- package/dist/test/QueryCursorLoader.test.js +122 -95
- package/dist/test/QueryCursorLoader.test.js.map +1 -1
- package/dist/test/recordPageErrorStates.test.d.ts +2 -0
- package/dist/test/recordPageErrorStates.test.d.ts.map +1 -0
- package/dist/test/recordPageErrorStates.test.js +37 -0
- package/dist/test/recordPageErrorStates.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/jest.config.js +8 -0
- package/package.json +4 -3
- package/src/pages/HashGeneratorPage.tsx +0 -1
- package/src/pages/RecordFormPage.tsx +13 -7
- package/src/pages/RecordTablePage.tsx +14 -11
- package/src/pages/TablesPage.tsx +0 -1
- package/src/pages/UuidGeneratorPage.tsx +0 -1
- package/src/table/QueryCursorLoader.ts +23 -43
- package/test/QueryCursorLoader.test.ts +87 -94
- package/test/recordPageErrorStates.test.tsx +35 -0
|
@@ -2,7 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Page, PageComponentProps } from '@proteinjs/ui';
|
|
3
3
|
import { tableByName, Table } from '@proteinjs/db';
|
|
4
4
|
import { RecordTable } from '../table/RecordTable';
|
|
5
|
-
import { Box, Paper, SxProps, Theme } from '@mui/material';
|
|
5
|
+
import { Box, Paper, SxProps, Theme, Typography } from '@mui/material';
|
|
6
6
|
|
|
7
7
|
export const recordTablePage: Page = {
|
|
8
8
|
name: 'Record Table',
|
|
@@ -10,19 +10,14 @@ export const recordTablePage: Page = {
|
|
|
10
10
|
auth: {
|
|
11
11
|
allUsers: true,
|
|
12
12
|
},
|
|
13
|
+
// No height override: the app's page container owns viewport height (dvh on mobile —
|
|
14
|
+
// a 100vh pin here clipped the bottom behind mobile browser chrome).
|
|
13
15
|
pageContainerSxProps: (theme: Theme): SxProps => {
|
|
14
16
|
return {
|
|
15
|
-
height: '100vh',
|
|
16
17
|
backgroundColor: theme.palette.background.default,
|
|
17
18
|
};
|
|
18
19
|
},
|
|
19
|
-
component: ({ ...props }) =>
|
|
20
|
-
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', padding: 4 }}>
|
|
21
|
-
<Paper sx={{ maxHeight: '80vh' }}>
|
|
22
|
-
<DynamicRecordTable {...props} />
|
|
23
|
-
</Paper>
|
|
24
|
-
</Box>
|
|
25
|
-
),
|
|
20
|
+
component: ({ ...props }) => <DynamicRecordTable {...props} />,
|
|
26
21
|
};
|
|
27
22
|
|
|
28
23
|
export const recordTableLink = (table: Table<any>) => {
|
|
@@ -48,11 +43,19 @@ const DynamicRecordTable = ({ urlParams }: PageComponentProps) => {
|
|
|
48
43
|
errorMessage = `Table not provided via the 'name' url param`;
|
|
49
44
|
}
|
|
50
45
|
|
|
46
|
+
// The error state renders as a plain message — wrapping it in the table's stretched
|
|
47
|
+
// card produced a full-height empty Paper with the text clipped at its edge.
|
|
51
48
|
if (!table) {
|
|
52
|
-
return <
|
|
49
|
+
return <Typography sx={{ p: 3, color: 'text.secondary' }}>{errorMessage}</Typography>;
|
|
53
50
|
}
|
|
54
51
|
|
|
55
|
-
return
|
|
52
|
+
return (
|
|
53
|
+
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', padding: 4 }}>
|
|
54
|
+
<Paper sx={{ maxHeight: '80vh' }}>
|
|
55
|
+
<RecordTable table={table} />
|
|
56
|
+
</Paper>
|
|
57
|
+
</Box>
|
|
58
|
+
);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
return <Table />;
|
package/src/pages/TablesPage.tsx
CHANGED
|
@@ -1,70 +1,50 @@
|
|
|
1
|
-
import { QueryBuilder, QueryOptions, Record, SortCriteria, Table
|
|
1
|
+
import { CursorWindowPager, QueryBuilder, QueryOptions, Record, SortCriteria, Table } from '@proteinjs/db';
|
|
2
2
|
import { CursorLoader, CursorValue, CursorWindow, ReactQueryKeys } from '@proteinjs/ui';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `CursorLoader` over the
|
|
6
|
-
*
|
|
7
|
-
* on a fresh build of the base query, so
|
|
8
|
-
* shift the window frame
|
|
9
|
-
*
|
|
5
|
+
* `CursorLoader` over the db-owned cursor-window machinery (`CursorWindowPager`): each window
|
|
6
|
+
* carries a lexicographic continuation past the anchor row — the primary sort axes plus a
|
|
7
|
+
* unique `id` tiebreak — and `paginate(0, windowSize)` on a fresh build of the base query, so
|
|
8
|
+
* rows created or removed while paging can never shift the window frame, and ties on the sort
|
|
9
|
+
* axis can never skip or duplicate boundary rows. Cursors are the pager's anchors encoded as
|
|
10
|
+
* strings (house `Serializer`, so dates/moments survive react-query page params).
|
|
10
11
|
*
|
|
11
12
|
* `dataKey` is the table name — table-level invalidation (`useTableMutation`) reaches every
|
|
12
13
|
* cursor query over it; `dataQueryKey` is the serialized query + sort.
|
|
13
14
|
*/
|
|
14
15
|
export class QueryCursorLoader<T extends Record> implements CursorLoader<T> {
|
|
15
16
|
reactQueryKeys: ReactQueryKeys;
|
|
16
|
-
private
|
|
17
|
+
private pager: CursorWindowPager<T>;
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* @param table the table to load windows from
|
|
20
21
|
* @param createQuery factory producing a FRESH conditions-only QueryBuilder per call —
|
|
21
|
-
* builders are mutable, so every window builds its own to hang the cursor
|
|
22
|
+
* builders are mutable, so every window builds its own to hang the cursor conditions,
|
|
22
23
|
* sort, and pagination on
|
|
23
24
|
* @param sort the sort criteria to apply to every window; the primary criterion is the
|
|
24
|
-
* cursor axis
|
|
25
|
+
* cursor axis (the pager appends the `id` tiebreak)
|
|
25
26
|
* @param queryOptions query options passed through to every window query
|
|
26
27
|
*/
|
|
27
28
|
constructor(
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
table: Table<T>,
|
|
30
|
+
createQuery: () => QueryBuilder<T>,
|
|
30
31
|
sort?: SortCriteria<T>[],
|
|
31
|
-
|
|
32
|
+
queryOptions?: QueryOptions<T>
|
|
32
33
|
) {
|
|
33
|
-
|
|
34
|
+
const effectiveSort = sort ? sort : [{ field: 'created' as keyof T, desc: true }];
|
|
35
|
+
this.pager = new CursorWindowPager<T>(table, createQuery, effectiveSort, { queryOptions });
|
|
34
36
|
this.reactQueryKeys = {
|
|
35
|
-
dataKey:
|
|
36
|
-
dataQueryKey: JSON.stringify({ query:
|
|
37
|
+
dataKey: table.name,
|
|
38
|
+
dataQueryKey: JSON.stringify({ query: createQuery(), sort: effectiveSort }),
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
async loadWindow(cursor: CursorValue | null, windowSize: number): Promise<CursorWindow<T>> {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
return {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const qb = this.createQuery();
|
|
48
|
-
const primarySort = this.sort[0];
|
|
49
|
-
if (cursor !== null) {
|
|
50
|
-
qb.condition({
|
|
51
|
-
field: primarySort.field,
|
|
52
|
-
operator: primarySort.desc ? '<' : '>',
|
|
53
|
-
value: cursor as T[keyof T],
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
return qb.sort(this.sort).paginate({ start: 0, end: windowSize });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
private deriveNextCursor(rows: T[], windowSize: number): CursorValue | null {
|
|
60
|
-
// A short window means the data set is exhausted; a full one implies more may exist —
|
|
61
|
-
// the next (short or empty) window settles it.
|
|
62
|
-
if (rows.length < windowSize) {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
// NULL cursor-field rows collect at the tail under the sort, and a null tail can't
|
|
66
|
-
// anchor a cursor: the data set's dated rows are exhausted, so paging stops here.
|
|
67
|
-
const tail = rows[rows.length - 1][this.sort[0].field];
|
|
68
|
-
return tail == null ? null : (tail as unknown as CursorValue);
|
|
43
|
+
const anchor = cursor === null ? null : CursorWindowPager.decodeAnchor(cursor as string);
|
|
44
|
+
const window = await this.pager.loadWindow(anchor, windowSize);
|
|
45
|
+
return {
|
|
46
|
+
rows: window.rows,
|
|
47
|
+
nextCursor: window.nextAnchor ? CursorWindowPager.encodeAnchor(window.nextAnchor) : null,
|
|
48
|
+
};
|
|
69
49
|
}
|
|
70
50
|
}
|
|
@@ -1,35 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* QueryCursorLoader (src/table/QueryCursorLoader.ts)
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* the
|
|
2
|
+
* QueryCursorLoader (src/table/QueryCursorLoader.ts) as the thin `CursorLoader` adapter over
|
|
3
|
+
* the db-owned `CursorWindowPager`: react-query key derivation, string-cursor encode/decode
|
|
4
|
+
* threading into the pager's lexicographic window queries (primary axis + `id` tiebreak), and
|
|
5
|
+
* exhaustion (short window, and the null-tail rule: NULL sort-value rows collect at the tail
|
|
6
|
+
* under the sort; a null can't anchor a continuation, so paging stops). The db round trip is
|
|
7
|
+
* injected through the pager's `options.db` seam via the house internals-cast pattern — no
|
|
8
|
+
* module mocking.
|
|
7
9
|
*/
|
|
8
10
|
import moment from 'moment';
|
|
9
|
-
|
|
10
|
-
const stagedRows: { rows: unknown[] } = { rows: [] };
|
|
11
|
-
const queries: unknown[] = [];
|
|
12
|
-
const stubDb = {
|
|
13
|
-
query: async (_table: unknown, qb: unknown) => {
|
|
14
|
-
queries.push(qb);
|
|
15
|
-
return stagedRows.rows;
|
|
16
|
-
},
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
jest.mock('@proteinjs/db', () => ({
|
|
20
|
-
...jest.requireActual('@proteinjs/db'),
|
|
21
|
-
getDb: () => stubDb,
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
import { QueryBuilder, SortCriteria, Table } from '@proteinjs/db';
|
|
25
|
-
import { CursorValue } from '@proteinjs/ui';
|
|
11
|
+
import { Db, QueryBuilder, SortCriteria, Table } from '@proteinjs/db';
|
|
26
12
|
import { QueryCursorLoader } from '../src/table/QueryCursorLoader';
|
|
13
|
+
// Load the reflection source graph so the house Serializer's custom serializers (moments)
|
|
14
|
+
// are registered — cursor encode/decode fidelity rides them.
|
|
15
|
+
import '../generated/index';
|
|
27
16
|
|
|
28
17
|
type Row = {
|
|
29
18
|
id: string;
|
|
30
19
|
created: moment.Moment;
|
|
31
20
|
updated: moment.Moment;
|
|
32
|
-
lastActivityAt?: moment.Moment;
|
|
21
|
+
lastActivityAt?: moment.Moment | null;
|
|
33
22
|
isPinned?: boolean;
|
|
34
23
|
};
|
|
35
24
|
|
|
@@ -37,13 +26,23 @@ const table = { name: 'test_content' } as Table<Row>;
|
|
|
37
26
|
const sort: SortCriteria<Row>[] = [{ field: 'lastActivityAt', desc: true }];
|
|
38
27
|
const createQuery = () => new QueryBuilder<Row>(table.name);
|
|
39
28
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
29
|
+
const staged: { rows: Row[] } = { rows: [] };
|
|
30
|
+
const executed: QueryBuilder<Row>[] = [];
|
|
31
|
+
const stubDb = {
|
|
32
|
+
query: async (_table: unknown, qb: QueryBuilder<Row>) => {
|
|
33
|
+
executed.push(qb);
|
|
34
|
+
return staged.rows;
|
|
35
|
+
},
|
|
36
|
+
} as unknown as Db;
|
|
37
|
+
|
|
38
|
+
/** House pattern: private internals reachable via a typed cast on the instance. */
|
|
39
|
+
type LoaderInternals = { pager: { options?: { db?: Db } } };
|
|
45
40
|
|
|
46
|
-
const
|
|
41
|
+
const loaderWithStubDb = (sortCriteria?: SortCriteria<Row>[], query: () => QueryBuilder<Row> = createQuery) => {
|
|
42
|
+
const loader = new QueryCursorLoader<Row>(table, query, sortCriteria);
|
|
43
|
+
(loader as unknown as LoaderInternals).pager.options = { db: stubDb };
|
|
44
|
+
return loader;
|
|
45
|
+
};
|
|
47
46
|
|
|
48
47
|
const graphNodes = (qb: QueryBuilder<Row>): any[] => qb.graph.nodes().map((id: string) => qb.graph.node(id));
|
|
49
48
|
const conditionNodes = (qb: QueryBuilder<Row>) => graphNodes(qb).filter((node) => node?.type === 'CONDITION');
|
|
@@ -54,12 +53,12 @@ const dated = (id: string, at: string | null): Row => ({
|
|
|
54
53
|
id,
|
|
55
54
|
created: moment(),
|
|
56
55
|
updated: moment(),
|
|
57
|
-
lastActivityAt: at ? moment(at) :
|
|
56
|
+
lastActivityAt: at ? moment(at) : null,
|
|
58
57
|
});
|
|
59
58
|
|
|
60
59
|
beforeEach(() => {
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
staged.rows = [];
|
|
61
|
+
executed.length = 0;
|
|
63
62
|
});
|
|
64
63
|
|
|
65
64
|
describe('reactQueryKeys', () => {
|
|
@@ -81,86 +80,80 @@ describe('reactQueryKeys', () => {
|
|
|
81
80
|
});
|
|
82
81
|
});
|
|
83
82
|
|
|
84
|
-
describe('
|
|
85
|
-
it('the first window carries no cursor
|
|
86
|
-
const loader =
|
|
87
|
-
|
|
83
|
+
describe('loadWindow — cursor threading', () => {
|
|
84
|
+
it('the first window carries no cursor conditions — the sort (with id tiebreak) and the window pagination', async () => {
|
|
85
|
+
const loader = loaderWithStubDb(sort);
|
|
86
|
+
staged.rows = [];
|
|
87
|
+
await loader.loadWindow(null, 30);
|
|
88
|
+
expect(executed).toHaveLength(1);
|
|
89
|
+
const qb = executed[0];
|
|
88
90
|
expect(conditionNodes(qb)).toHaveLength(0);
|
|
89
91
|
expect(paginationNode(qb)).toMatchObject({ start: 0, end: 30 });
|
|
90
|
-
expect(sortNodes(qb)).
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
it('a cursor becomes `field < cursor` on the primary sort field for a descending sort', () => {
|
|
95
|
-
const loader = new QueryCursorLoader<Row>(table, createQuery, sort);
|
|
96
|
-
const cursor = moment('2026-08-10T12:00:00.000Z');
|
|
97
|
-
const qb = internals(loader).buildWindowQuery(cursor, 30);
|
|
98
|
-
const conditions = conditionNodes(qb);
|
|
99
|
-
expect(conditions).toHaveLength(1);
|
|
100
|
-
expect(conditions[0]).toMatchObject({ field: 'lastActivityAt', operator: '<' });
|
|
101
|
-
expect(conditions[0].value).toBe(cursor);
|
|
102
|
-
expect(paginationNode(qb)).toMatchObject({ start: 0, end: 30 });
|
|
92
|
+
expect(sortNodes(qb).map((node) => node.criteria)).toEqual([
|
|
93
|
+
{ field: 'lastActivityAt', desc: true },
|
|
94
|
+
{ field: 'id', desc: false },
|
|
95
|
+
]);
|
|
103
96
|
});
|
|
104
97
|
|
|
105
|
-
it('
|
|
106
|
-
const loader =
|
|
107
|
-
const qb = internals(loader).buildWindowQuery(5, 10);
|
|
108
|
-
expect(conditionNodes(qb)[0]).toMatchObject({ field: 'lastActivityAt', operator: '>', value: 5 });
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('every window builds on a FRESH query — cursor conditions never accumulate across windows', () => {
|
|
112
|
-
const loader = new QueryCursorLoader<Row>(table, createQuery, sort);
|
|
113
|
-
internals(loader).buildWindowQuery(moment('2026-08-10T12:00:00.000Z'), 30);
|
|
114
|
-
const second = internals(loader).buildWindowQuery(moment('2026-08-01T12:00:00.000Z'), 30);
|
|
115
|
-
expect(conditionNodes(second)).toHaveLength(1);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe('deriveNextCursor — exhaustion', () => {
|
|
120
|
-
const loader = new QueryCursorLoader<Row>(table, createQuery, sort);
|
|
121
|
-
|
|
122
|
-
it('a short window is exhausted', () => {
|
|
123
|
-
expect(internals(loader).deriveNextCursor([dated('a', '2026-08-10T12:00:00.000Z')], 3)).toBeNull();
|
|
124
|
-
expect(internals(loader).deriveNextCursor([], 3)).toBeNull();
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('a full window anchors the next cursor at its tail', () => {
|
|
98
|
+
it('a full window returns a string cursor; feeding it back threads the lexicographic continuation', async () => {
|
|
99
|
+
const loader = loaderWithStubDb(sort);
|
|
128
100
|
const rows = [
|
|
129
101
|
dated('a', '2026-08-10T12:00:00.000Z'),
|
|
130
102
|
dated('b', '2026-08-09T12:00:00.000Z'),
|
|
131
103
|
dated('c', '2026-08-08T12:00:00.000Z'),
|
|
132
104
|
];
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
105
|
+
staged.rows = rows;
|
|
106
|
+
const first = await loader.loadWindow(null, 3);
|
|
107
|
+
expect(first.rows).toBe(rows);
|
|
108
|
+
expect(typeof first.nextCursor).toBe('string');
|
|
109
|
+
|
|
110
|
+
staged.rows = [];
|
|
111
|
+
await loader.loadWindow(first.nextCursor, 3);
|
|
112
|
+
const qb = executed[1];
|
|
113
|
+
// Past-on-axis OR (equal-on-axis AND past-on-id), anchored at the tail row 'c' — with the
|
|
114
|
+
// moment surviving the string cursor round trip.
|
|
115
|
+
const conditions = conditionNodes(qb);
|
|
116
|
+
expect(conditions).toHaveLength(3);
|
|
117
|
+
const past = conditions.find((node) => node.operator === '<');
|
|
118
|
+
expect(past).toMatchObject({ field: 'lastActivityAt' });
|
|
119
|
+
expect(moment.isMoment(past.value)).toBe(true);
|
|
120
|
+
expect(past.value.toISOString()).toBe(rows[2].lastActivityAt!.toISOString());
|
|
121
|
+
expect(conditions.find((node) => node.operator === '=')).toMatchObject({ field: 'lastActivityAt' });
|
|
122
|
+
expect(conditions.find((node) => node.operator === '>')).toMatchObject({ field: 'id', value: 'c' });
|
|
140
123
|
});
|
|
141
|
-
});
|
|
142
124
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const rows = [
|
|
125
|
+
it('every window builds on a FRESH query — cursor conditions never accumulate across windows', async () => {
|
|
126
|
+
const loader = loaderWithStubDb(sort);
|
|
127
|
+
staged.rows = [
|
|
147
128
|
dated('a', '2026-08-10T12:00:00.000Z'),
|
|
148
129
|
dated('b', '2026-08-09T12:00:00.000Z'),
|
|
149
130
|
dated('c', '2026-08-08T12:00:00.000Z'),
|
|
150
131
|
];
|
|
151
|
-
|
|
132
|
+
const first = await loader.loadWindow(null, 3);
|
|
133
|
+
staged.rows = [
|
|
134
|
+
dated('d', '2026-08-07T12:00:00.000Z'),
|
|
135
|
+
dated('e', '2026-08-06T12:00:00.000Z'),
|
|
136
|
+
dated('f', '2026-08-05T12:00:00.000Z'),
|
|
137
|
+
];
|
|
138
|
+
const second = await loader.loadWindow(first.nextCursor, 3);
|
|
139
|
+
staged.rows = [];
|
|
140
|
+
await loader.loadWindow(second.nextCursor, 3);
|
|
141
|
+
expect(conditionNodes(executed[2])).toHaveLength(3);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('loadWindow — exhaustion', () => {
|
|
146
|
+
it('a short window is exhausted (null cursor)', async () => {
|
|
147
|
+
const loader = loaderWithStubDb(sort);
|
|
148
|
+
staged.rows = [dated('a', '2026-08-10T12:00:00.000Z')];
|
|
152
149
|
const window = await loader.loadWindow(null, 3);
|
|
153
|
-
expect(window.
|
|
154
|
-
expect(window.nextCursor).toBe(rows[2].lastActivityAt);
|
|
155
|
-
// The executed query is the built window query: pagination sized to the window.
|
|
156
|
-
expect(queries).toHaveLength(1);
|
|
157
|
-
expect(paginationNode(queries[0] as QueryBuilder<Row>)).toMatchObject({ start: 0, end: 3 });
|
|
150
|
+
expect(window.nextCursor).toBeNull();
|
|
158
151
|
});
|
|
159
152
|
|
|
160
|
-
it('
|
|
161
|
-
const loader =
|
|
162
|
-
|
|
163
|
-
const window = await loader.loadWindow(
|
|
153
|
+
it('a full window with a NULL sort-value tail is exhausted (null rows collect at the tail; no anchor)', async () => {
|
|
154
|
+
const loader = loaderWithStubDb(sort);
|
|
155
|
+
staged.rows = [dated('a', '2026-08-10T12:00:00.000Z'), dated('b', '2026-08-09T12:00:00.000Z'), dated('c', null)];
|
|
156
|
+
const window = await loader.loadWindow(null, 3);
|
|
164
157
|
expect(window.nextCursor).toBeNull();
|
|
165
158
|
});
|
|
166
159
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { recordFormPage } from '../src/pages/RecordFormPage';
|
|
4
|
+
import { recordTablePage } from '../src/pages/RecordTablePage';
|
|
5
|
+
|
|
6
|
+
describe('record form page error states', () => {
|
|
7
|
+
it('renders the not-accessible message for an unknown table (regression: a shadowed catch binding swallowed the message and the page rendered blank)', () => {
|
|
8
|
+
const Form = recordFormPage.component;
|
|
9
|
+
const html = renderToStaticMarkup(<Form urlParams={{ table: 'NotARealTable' }} />);
|
|
10
|
+
expect(html).toContain('Table not accessible in UI: NotARealTable');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders the missing-param message when no table is provided', () => {
|
|
14
|
+
const Form = recordFormPage.component;
|
|
15
|
+
const html = renderToStaticMarkup(<Form urlParams={{}} />);
|
|
16
|
+
// (apostrophes render HTML-escaped in static markup, so assert around them)
|
|
17
|
+
expect(html).toContain('Table name not provided via the');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('record table page error states', () => {
|
|
22
|
+
it('renders the not-accessible message outside the table card (the stretched Paper clipped it)', () => {
|
|
23
|
+
const Table = recordTablePage.component;
|
|
24
|
+
const html = renderToStaticMarkup(<Table urlParams={{ name: 'NotARealTable' }} />);
|
|
25
|
+
expect(html).toContain('Table not accessible in UI: NotARealTable');
|
|
26
|
+
expect(html).not.toContain('MuiPaper');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders the missing-param message when no name is provided', () => {
|
|
30
|
+
const Table = recordTablePage.component;
|
|
31
|
+
const html = renderToStaticMarkup(<Table urlParams={{}} />);
|
|
32
|
+
// (apostrophes render HTML-escaped in static markup, so assert around them)
|
|
33
|
+
expect(html).toContain('Table not provided via the');
|
|
34
|
+
});
|
|
35
|
+
});
|