@startsimpli/ui 0.4.116 → 0.4.118
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/package.json +5 -5
- package/src/components/collection/DraftReviewWorkspace.tsx +4 -4
- package/src/components/collection/__tests__/drafts.test.ts +99 -1
- package/src/components/collection/__tests__/translated-draft-word-chip.test.tsx +129 -0
- package/src/components/collection/drafts.ts +44 -1
- package/src/components/collection/index.ts +1 -0
- package/src/components/unified-table/UnifiedTable.tsx +3 -0
- package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +71 -8
- package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +37 -0
- package/src/components/unified-table/__tests__/integration/pagination-range.test.tsx +183 -0
- package/src/components/unified-table/hooks/usePagination.ts +34 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.118",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"react": "^18.0.0 || ^19.0.0",
|
|
49
49
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
50
50
|
"@startsimpli/api": "^0.5.58",
|
|
51
|
-
"@startsimpli/
|
|
52
|
-
"@startsimpli/
|
|
51
|
+
"@startsimpli/auth": "^0.4.35",
|
|
52
|
+
"@startsimpli/i18n": "^0.1.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@startsimpli/api": {
|
|
@@ -123,8 +123,8 @@
|
|
|
123
123
|
"tailwindcss": "^3.4.19",
|
|
124
124
|
"tsup": "^8.5.1",
|
|
125
125
|
"typescript": "^6.0.3",
|
|
126
|
-
"@startsimpli/
|
|
127
|
-
"@startsimpli/
|
|
126
|
+
"@startsimpli/i18n": "0.1.3",
|
|
127
|
+
"@startsimpli/realtime": "0.0.2"
|
|
128
128
|
},
|
|
129
129
|
"keywords": [
|
|
130
130
|
"ui",
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
isChosen,
|
|
28
28
|
gradeWords,
|
|
29
29
|
wordTargets,
|
|
30
|
+
draftWordCount,
|
|
30
31
|
sourceOverlapCheck,
|
|
31
32
|
pickChanges,
|
|
32
33
|
isRejectedTopicGroup,
|
|
@@ -332,8 +333,7 @@ function CandidateCard({
|
|
|
332
333
|
const chosen = isChosen(record);
|
|
333
334
|
const status = field(record, 'status');
|
|
334
335
|
const preview = field(record, 'blog').slice(0, 150);
|
|
335
|
-
const
|
|
336
|
-
const blogWords = Number(get(checks, 'blogWords', 'blog_words')) || 0;
|
|
336
|
+
const blogWords = draftWordCount(record, 'blog');
|
|
337
337
|
const target = wordTargets(contentType).blog;
|
|
338
338
|
return (
|
|
339
339
|
<div className={`flex flex-col rounded-md border p-3 ${chosen ? 'border-emerald-400 bg-emerald-50/40' : 'border-border bg-white'}`}>
|
|
@@ -407,8 +407,8 @@ function ContentReview({
|
|
|
407
407
|
})();
|
|
408
408
|
|
|
409
409
|
const targets = wordTargets(contentType);
|
|
410
|
-
const blogWords =
|
|
411
|
-
const liWords =
|
|
410
|
+
const blogWords = draftWordCount(record, 'blog');
|
|
411
|
+
const liWords = draftWordCount(record, 'linkedin');
|
|
412
412
|
const hype = get(checks, 'hypeFlags', 'hype_flags');
|
|
413
413
|
const approvedSources = get(checks, 'approvedSources', 'approved_sources');
|
|
414
414
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets, sourceOverlapCheck, pickChanges, isRejectedTopicGroup } from '../drafts';
|
|
1
|
+
import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets, sourceOverlapCheck, pickChanges, isRejectedTopicGroup, draftWordCount } from '../drafts';
|
|
2
|
+
import { wordCount } from '../../validation';
|
|
2
3
|
import type { EntityRecord } from '../types';
|
|
3
4
|
|
|
4
5
|
function rec(id: string, name: string, data: Record<string, unknown>): EntityRecord {
|
|
@@ -151,3 +152,100 @@ describe('isRejectedTopicGroup (startsim-ay9l — collapse drafts of rejected to
|
|
|
151
152
|
expect(isRejectedTopicGroup(g, new Set(['']))).toBe(false);
|
|
152
153
|
});
|
|
153
154
|
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A draft with no stored auto-checks still has a LENGTH (bd startsim-8hgmq.15).
|
|
158
|
+
*
|
|
159
|
+
* OBSERVED on the live tenant, 2026-09-07, at /drafts: a Chinese candidate card
|
|
160
|
+
* read "blog 0w … unjudged". The draft was 1027 characters, 855 of them CJK.
|
|
161
|
+
* The workspace was reading the count out of the stored `auto_checks` blob with
|
|
162
|
+
* `Number(get(checks, 'blogWords', 'blog_words')) || 0`, and the blob is null on
|
|
163
|
+
* every translated draft — `auto_checks` is written by the generator, while a
|
|
164
|
+
* translation is made by the tenant's own translate-draft, which deliberately
|
|
165
|
+
* does not inherit checks that measured text the checker never saw. So
|
|
166
|
+
* `Number(undefined) || 0` announced every translation as empty.
|
|
167
|
+
*
|
|
168
|
+
* The three live rows, all `auto_checks: null` and `judge_verdict: null`:
|
|
169
|
+
* f6a2d629 zh 1027 chars (855 CJK)
|
|
170
|
+
* 147d0eb8 zh 1109 chars (972 CJK)
|
|
171
|
+
* d0ca457a ar 2998 chars
|
|
172
|
+
*
|
|
173
|
+
* "unjudged" was honest — no judge ran. "0w" was not: nothing measured this
|
|
174
|
+
* draft, and an absent check must never render as a zero measurement.
|
|
175
|
+
*/
|
|
176
|
+
describe('draftWordCount (startsim-8hgmq.15 — a missing check is not a zero)', () => {
|
|
177
|
+
// The opening of the live zh draft f6a2d629, not invented text — the same
|
|
178
|
+
// fixture the counter's own tests use (validation/__tests__/word-count-scripts).
|
|
179
|
+
const ZH_BLOG =
|
|
180
|
+
'来自《Arabian Business》的报道显示,海湾—亚洲技术走廊正进入新阶段,' +
|
|
181
|
+
'中国企业正从单纯投资转向在GCC范围内更深度嵌入的运营模式。';
|
|
182
|
+
const EN_BLOG =
|
|
183
|
+
'The Gulf-Asia technology corridor is entering a new phase, with Chinese firms ' +
|
|
184
|
+
'moving from investment into deeper operational embedding across the GCC.';
|
|
185
|
+
|
|
186
|
+
it('counts the draft itself when the translation carries no auto_checks', () => {
|
|
187
|
+
// The reported card. Whitespace counting scores this text 2 and the old
|
|
188
|
+
// stored-only read scored it 0; either way the reviewer was told the draft
|
|
189
|
+
// was empty.
|
|
190
|
+
const zh = rec('f6a2d629', '中国—海湾科技扩张引发入局者对执行的疑问', {
|
|
191
|
+
candidate_index: 1,
|
|
192
|
+
lang: 'zh',
|
|
193
|
+
blog: ZH_BLOG,
|
|
194
|
+
auto_checks: null,
|
|
195
|
+
judge_verdict: null,
|
|
196
|
+
});
|
|
197
|
+
expect(draftWordCount(zh, 'blog')).toBeGreaterThan(20);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('falls back for linkedin the same way', () => {
|
|
201
|
+
const zh = rec('f6a2d629', 'zh', { lang: 'zh', linkedin: ZH_BLOG, auto_checks: null });
|
|
202
|
+
expect(draftWordCount(zh, 'linkedin')).toBeGreaterThan(20);
|
|
203
|
+
expect(draftWordCount(zh, 'blog')).toBe(0); // no blog field at all IS zero
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('is a FALLBACK, not a replacement — a stored count wins', () => {
|
|
207
|
+
// Without this, "always recompute" passes the test above while quietly
|
|
208
|
+
// dropping the pipeline's own measurement, which is the number the judge and
|
|
209
|
+
// the writer agreed on.
|
|
210
|
+
const en = rec('d1', 'EN', { blog: EN_BLOG, auto_checks: { blog_words: 431, linkedin_words: 118 } });
|
|
211
|
+
expect(draftWordCount(en, 'blog')).toBe(431);
|
|
212
|
+
expect(draftWordCount(en, 'linkedin')).toBe(118);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('reads the camelCased blob the API client returns', () => {
|
|
216
|
+
// The tenant client camelCases response keys, data blob included, so the
|
|
217
|
+
// same record arrives as autoChecks.blogWords over the wire.
|
|
218
|
+
const en = rec('d1', 'EN', { blog: EN_BLOG, autoChecks: { blogWords: 431 } });
|
|
219
|
+
expect(draftWordCount(en, 'blog')).toBe(431);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('keeps a stored zero, which is a measurement rather than an absence', () => {
|
|
223
|
+
const empty = rec('d2', 'EN', { blog: '', auto_checks: { blog_words: 0 } });
|
|
224
|
+
expect(draftWordCount(empty, 'blog')).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('recomputes when the blob exists but lacks this field', () => {
|
|
228
|
+
// A checker that ran on the blog only must not make the LinkedIn post read 0.
|
|
229
|
+
const partial = rec('d3', 'EN', { blog: EN_BLOG, linkedin: EN_BLOG, auto_checks: { blog_words: 431 } });
|
|
230
|
+
expect(draftWordCount(partial, 'blog')).toBe(431);
|
|
231
|
+
expect(draftWordCount(partial, 'linkedin')).toBe(23);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('recomputes rather than trusting an unparseable stored value', () => {
|
|
235
|
+
const junk = rec('d4', 'EN', { blog: EN_BLOG, auto_checks: { blog_words: 'n/a' } });
|
|
236
|
+
expect(draftWordCount(junk, 'blog')).toBe(23);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('agrees with the shared counter, so the chip and the rail cannot contradict', () => {
|
|
240
|
+
// startsim-wn2p.28: the count was wrong in four places because it was
|
|
241
|
+
// written in four places, and the screen ended up saying "524 words" and
|
|
242
|
+
// "16w" about one draft. This must stay a call to that one function.
|
|
243
|
+
const en = rec('d5', 'EN', { blog: EN_BLOG, auto_checks: null });
|
|
244
|
+
expect(draftWordCount(en, 'blog')).toBe(wordCount(EN_BLOG));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('is 0 for a draft that really is empty', () => {
|
|
248
|
+
expect(draftWordCount(rec('d6', 'EN', { blog: '', auto_checks: null }), 'blog')).toBe(0);
|
|
249
|
+
expect(draftWordCount(rec('d7', 'EN', {}), 'blog')).toBe(0);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compare workspace must not tell a reviewer that a full draft is empty
|
|
3
|
+
* (bd startsim-8hgmq.15).
|
|
4
|
+
*
|
|
5
|
+
* OBSERVED on the live tenant, 2026-09-07, at /drafts. A Chinese candidate card
|
|
6
|
+
* in the "How Chinese Firms Are Anchoring Into the Gulf Tech Corridor" set read
|
|
7
|
+
*
|
|
8
|
+
* blog 0w unjudged
|
|
9
|
+
*
|
|
10
|
+
* and the draft behind it was 1027 characters, 855 of them CJK. Half of that
|
|
11
|
+
* card was honest: no judge ran on a translation, so "unjudged" is true. The
|
|
12
|
+
* other half was a MISSING measurement rendered as a real zero — the card read
|
|
13
|
+
* the count out of the stored `auto_checks` blob, which a translation never
|
|
14
|
+
* gets, and `Number(undefined) || 0` is 0.
|
|
15
|
+
*
|
|
16
|
+
* `drafts.test.ts` pins the counting rule. This pins the SCREEN, at the level
|
|
17
|
+
* the defect was reported at: mount the workspace over a stubbed tenant client
|
|
18
|
+
* holding the three real drafts and read the chips.
|
|
19
|
+
*/
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
22
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
23
|
+
|
|
24
|
+
// react-markdown is ESM and this suite is not; the workspace pulls it in only
|
|
25
|
+
// to render an OPEN draft's body, which no assertion here reads.
|
|
26
|
+
jest.mock('../../blog/MarkdownRenderer', () => ({
|
|
27
|
+
MarkdownRenderer: ({ content }: { content: string }) => <div>{content}</div>,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { DraftReviewWorkspace } from '../DraftReviewWorkspace';
|
|
31
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
|
|
32
|
+
|
|
33
|
+
const DRAFT_TYPE: EntityTypeDef = { id: 1, key: 'draft', label: 'Draft', attributes: [] };
|
|
34
|
+
|
|
35
|
+
function page<T>(results: T[]): Paginated<T> {
|
|
36
|
+
return { count: results.length, next: null, previous: null, results };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function draft(id: number, name: string, data: Record<string, unknown>): EntityRecord {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
entityType: 'draft',
|
|
43
|
+
externalId: null,
|
|
44
|
+
name,
|
|
45
|
+
data: { content_type: 'weekly_brief', topic_ref: 'topic-1', topic_title: 'Gulf tech corridor', ...data },
|
|
46
|
+
createdAt: '2026-09-01T00:00:00Z',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The opening of the live zh draft f6a2d629 — real text, not a repeat()ed glyph.
|
|
51
|
+
const ZH_BLOG =
|
|
52
|
+
'来自《Arabian Business》的报道显示,海湾—亚洲技术走廊正进入新阶段,' +
|
|
53
|
+
'中国企业正从单纯投资转向在GCC范围内更深度嵌入的运营模式。';
|
|
54
|
+
const EN_BLOG =
|
|
55
|
+
'The Gulf-Asia technology corridor is entering a new phase, with Chinese firms ' +
|
|
56
|
+
'moving from investment into deeper operational embedding across the GCC.';
|
|
57
|
+
|
|
58
|
+
/** The English original, which the writer checked, and its zh translation, which nothing checked. */
|
|
59
|
+
const DRAFTS: EntityRecord[] = [
|
|
60
|
+
draft(1, 'Candidate 1 — Gulf tech corridor', {
|
|
61
|
+
candidate_index: 1,
|
|
62
|
+
story_title: 'China-Gulf Tech Expansion Raises Execution Questions for Entrants',
|
|
63
|
+
lang: 'en',
|
|
64
|
+
blog: EN_BLOG,
|
|
65
|
+
auto_checks: { blog_words: 431, linkedin_words: 180 },
|
|
66
|
+
judge_verdict: { verdict: 'accept' },
|
|
67
|
+
}),
|
|
68
|
+
draft(2, 'Candidate 2 — Gulf tech corridor', {
|
|
69
|
+
candidate_index: 2,
|
|
70
|
+
story_title: '中国—海湾科技扩张引发入局者对执行的疑问',
|
|
71
|
+
lang: 'zh',
|
|
72
|
+
blog: ZH_BLOG,
|
|
73
|
+
// Exactly how the live translations are stored: the generator's checks and
|
|
74
|
+
// verdict are NOT inherited, because they measured text neither one saw.
|
|
75
|
+
auto_checks: null,
|
|
76
|
+
judge_verdict: null,
|
|
77
|
+
}),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
function stubClient(): CollectionClient {
|
|
81
|
+
return {
|
|
82
|
+
listTypes: jest.fn(async () => page([DRAFT_TYPE])),
|
|
83
|
+
listAllEntities: jest.fn(async (type: string) => (type === 'draft' ? DRAFTS : [])),
|
|
84
|
+
updateEntity: jest.fn(async () => DRAFTS[0]),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderWorkspace() {
|
|
89
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
90
|
+
return render(
|
|
91
|
+
<QueryClientProvider client={qc}>
|
|
92
|
+
<DraftReviewWorkspace client={stubClient()} />
|
|
93
|
+
</QueryClientProvider>,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The card for one candidate, found by the title the reviewer reads. */
|
|
98
|
+
async function card(title: string): Promise<HTMLElement> {
|
|
99
|
+
const heading = await screen.findByText(title);
|
|
100
|
+
const el = heading.closest('div.flex.flex-col');
|
|
101
|
+
if (!el) throw new Error(`no candidate card around "${title}"`);
|
|
102
|
+
return el as HTMLElement;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('a translated candidate card (startsim-8hgmq.15)', () => {
|
|
106
|
+
it('shows the draft’s real length instead of “blog 0w”', async () => {
|
|
107
|
+
renderWorkspace();
|
|
108
|
+
const zh = await card('中国—海湾科技扩张引发入局者对执行的疑问');
|
|
109
|
+
|
|
110
|
+
expect(within(zh).queryByText(/^blog 0w/)).toBeNull();
|
|
111
|
+
const chip = within(zh).getByText(/^blog \d+w/);
|
|
112
|
+
const words = Number(chip.textContent!.match(/blog (\d+)w/)![1]);
|
|
113
|
+
expect(words).toBeGreaterThan(20);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('still says “unjudged”, which was the honest half of that card', async () => {
|
|
117
|
+
// No judge ran on the translation. The fix is to stop faking the count, not
|
|
118
|
+
// to fake a verdict alongside it.
|
|
119
|
+
renderWorkspace();
|
|
120
|
+
const zh = await card('中国—海湾科技扩张引发入局者对执行的疑问');
|
|
121
|
+
expect(within(zh).getByText('unjudged')).toBeTruthy();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('leaves a checked candidate showing the count the pipeline stored', async () => {
|
|
125
|
+
renderWorkspace();
|
|
126
|
+
const en = await card('China-Gulf Tech Expansion Raises Execution Questions for Entrants');
|
|
127
|
+
await waitFor(() => expect(within(en).getByText(/^blog 431w/)).toBeTruthy());
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { EntityRecord } from './types';
|
|
11
11
|
import { readData } from './data';
|
|
12
|
-
import { runContentChecks, type ContentCheck } from '../validation';
|
|
12
|
+
import { runContentChecks, wordCount, type ContentCheck } from '../validation';
|
|
13
13
|
|
|
14
14
|
export interface DraftSource {
|
|
15
15
|
url: string;
|
|
@@ -160,6 +160,49 @@ export function wordTargets(contentType: string): { blog: [number, number]; link
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The word count to SHOW for one field of one draft (bd startsim-8hgmq.15).
|
|
165
|
+
*
|
|
166
|
+
* WHY THIS EXISTS. The workspace used to read the count straight out of the
|
|
167
|
+
* stored blob — `Number(get(checks, 'blogWords', 'blog_words')) || 0` — and on
|
|
168
|
+
* 2026-09-07 a live Chinese candidate at /drafts advertised itself as
|
|
169
|
+
* "blog 0w … unjudged". The draft was 1027 characters, 855 of them CJK.
|
|
170
|
+
*
|
|
171
|
+
* `auto_checks` is written by the pipeline that GENERATES a candidate. A
|
|
172
|
+
* TRANSLATION is created by the tenant's own translate-draft, which copies the
|
|
173
|
+
* prose and deliberately does not inherit the generator's checks or verdict —
|
|
174
|
+
* they measured text neither one ever saw. So `auto_checks` is null on every
|
|
175
|
+
* translation, `Number(undefined) || 0` is 0, and every translated draft in the
|
|
176
|
+
* compare workspace announced itself as empty. Structural, not a bad row: the
|
|
177
|
+
* three live translations (zh, zh, ar) all read 0w, and so would every future
|
|
178
|
+
* one. "unjudged" beside it was honest — no judge ran — but an ABSENT check must
|
|
179
|
+
* never render as a zero MEASUREMENT.
|
|
180
|
+
*
|
|
181
|
+
* ABSENCE FALLS BACK, FALSINESS DOES NOT. A stored `0` is a measurement the
|
|
182
|
+
* pipeline actually took and it survives; only a missing or unparseable value is
|
|
183
|
+
* recomputed. That is the whole bug: the old code tested truthiness where it
|
|
184
|
+
* meant presence.
|
|
185
|
+
*
|
|
186
|
+
* IT RECOUNTS RATHER THAN BLANKING because the reviewer's job here is comparing
|
|
187
|
+
* candidates, and "—" tells them nothing about whether this one runs long.
|
|
188
|
+
*
|
|
189
|
+
* IT CALLS THE SHARED `wordCount` WITH NO LOCALE, exactly as `runContentChecks`
|
|
190
|
+
* does, so the chip and the validation rail on the same screen cannot disagree
|
|
191
|
+
* about one draft. This is a FALLBACK TO that function, never a second
|
|
192
|
+
* implementation of it — bd startsim-wn2p.28 was one line of arithmetic that
|
|
193
|
+
* took four PRs because the count had been written in four places, and the
|
|
194
|
+
* half-fixed screen read "524 words" in the rail and "16w" on the tab above it.
|
|
195
|
+
*/
|
|
196
|
+
export function draftWordCount(r: EntityRecord, field: 'blog' | 'linkedin'): number {
|
|
197
|
+
const checks = readData(r.data, 'auto_checks');
|
|
198
|
+
const bag = checks && typeof checks === 'object' && !Array.isArray(checks) ? (checks as Record<string, unknown>) : null;
|
|
199
|
+
// Both spellings, because the tenant API client camelCases the data blob.
|
|
200
|
+
const stored = bag ? bag[`${field}Words`] ?? bag[`${field}_words`] : undefined;
|
|
201
|
+
const n = Number(stored);
|
|
202
|
+
if (stored != null && stored !== '' && Number.isFinite(n)) return n;
|
|
203
|
+
return wordCount(str(readData(r.data, field)));
|
|
204
|
+
}
|
|
205
|
+
|
|
163
206
|
export type CheckStatus = 'ok' | 'low' | 'high' | 'none';
|
|
164
207
|
|
|
165
208
|
/** Grade a word count against a [lo, hi] target. hi===0 means "no target". */
|
|
@@ -257,6 +257,9 @@ export function UnifiedTable<TData>({
|
|
|
257
257
|
initialPage: paginationConfig?.currentPage || (initialURLState?.page ?? 1),
|
|
258
258
|
serverSide: paginationConfig?.serverSide || false,
|
|
259
259
|
onPageChange: paginationConfig?.onPageChange,
|
|
260
|
+
// `sortedData` IS the body — every row of it is rendered below, nothing is
|
|
261
|
+
// sliced here — so it is the only honest source for "Showing X-Y".
|
|
262
|
+
renderedCount: sortedData.length,
|
|
260
263
|
})
|
|
261
264
|
|
|
262
265
|
// Selection hook - supports both controlled and uncontrolled modes
|
|
@@ -1,7 +1,40 @@
|
|
|
1
1
|
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
2
|
import { TablePagination } from '../../components/TablePagination'
|
|
3
|
+
import { usePagination } from '../../hooks/usePagination'
|
|
3
4
|
import { UsePaginationReturn } from '../../types'
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Drives the REAL hook.
|
|
8
|
+
*
|
|
9
|
+
* The count line is the one thing in this component that is arithmetic rather
|
|
10
|
+
* than markup, so a mocked `getDisplayRange` turns its test into a test of the
|
|
11
|
+
* mock — which is how "Showing 1-20" over a body of 50 rows survived on every
|
|
12
|
+
* table in the fleet (bd startsim-b9v6p). Mocking stays legitimate for the
|
|
13
|
+
* button/page-number blocks below, where the hook's answers are inputs to the
|
|
14
|
+
* markup rather than the thing under test.
|
|
15
|
+
*/
|
|
16
|
+
function RealPagination({
|
|
17
|
+
totalCount,
|
|
18
|
+
pageSize,
|
|
19
|
+
page = 1,
|
|
20
|
+
renderedCount,
|
|
21
|
+
totalFilteredCount,
|
|
22
|
+
}: {
|
|
23
|
+
totalCount: number
|
|
24
|
+
pageSize: number
|
|
25
|
+
page?: number
|
|
26
|
+
renderedCount?: number
|
|
27
|
+
totalFilteredCount?: number
|
|
28
|
+
}) {
|
|
29
|
+
const pagination = usePagination({
|
|
30
|
+
totalCount,
|
|
31
|
+
initialPageSize: pageSize,
|
|
32
|
+
initialPage: page,
|
|
33
|
+
renderedCount,
|
|
34
|
+
})
|
|
35
|
+
return <TablePagination pagination={pagination} totalFilteredCount={totalFilteredCount} />
|
|
36
|
+
}
|
|
37
|
+
|
|
5
38
|
describe('TablePagination', () => {
|
|
6
39
|
const createMockPagination = (overrides?: Partial<UsePaginationReturn>): UsePaginationReturn => ({
|
|
7
40
|
currentPage: 1,
|
|
@@ -22,8 +55,9 @@ describe('TablePagination', () => {
|
|
|
22
55
|
|
|
23
56
|
describe('Display', () => {
|
|
24
57
|
it('should render item count display', () => {
|
|
25
|
-
const
|
|
26
|
-
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<RealPagination totalCount={100} pageSize={25} renderedCount={25} />
|
|
60
|
+
)
|
|
27
61
|
|
|
28
62
|
// Find the item count display div by its specific class and verify content
|
|
29
63
|
const itemCountDiv = container.querySelector('.text-muted-foreground')
|
|
@@ -34,23 +68,52 @@ describe('TablePagination', () => {
|
|
|
34
68
|
expect(itemCountDiv?.textContent).toContain('100')
|
|
35
69
|
})
|
|
36
70
|
|
|
71
|
+
it('should count the rows on screen, not the declared page size', () => {
|
|
72
|
+
// A server page of 50 under a paginationConfig that declares 20 — the
|
|
73
|
+
// shape measured on every content view of the marketing-agents tenant.
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<RealPagination totalCount={5485} pageSize={20} renderedCount={50} />
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
expect(container.querySelector('.text-muted-foreground')?.textContent).toContain('1-50')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should count a whole client-rendered corpus', () => {
|
|
82
|
+
const { container } = render(
|
|
83
|
+
<RealPagination totalCount={68} pageSize={20} renderedCount={68} />
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
expect(container.querySelector('.text-muted-foreground')?.textContent).toContain('1-68')
|
|
87
|
+
})
|
|
88
|
+
|
|
37
89
|
it('should display current page and total pages', () => {
|
|
38
|
-
|
|
39
|
-
render(<TablePagination pagination={pagination} />)
|
|
90
|
+
render(<RealPagination totalCount={100} pageSize={25} page={2} renderedCount={25} />)
|
|
40
91
|
|
|
41
92
|
expect(screen.getByText(/Page 2 of 4/)).toBeInTheDocument()
|
|
42
93
|
})
|
|
43
94
|
|
|
44
95
|
it('should show filtered items indicator when totalFilteredCount differs from totalCount', () => {
|
|
45
|
-
|
|
46
|
-
|
|
96
|
+
render(
|
|
97
|
+
<RealPagination
|
|
98
|
+
totalCount={100}
|
|
99
|
+
pageSize={25}
|
|
100
|
+
renderedCount={25}
|
|
101
|
+
totalFilteredCount={50}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
47
104
|
|
|
48
105
|
expect(screen.getByText(/filtered items/)).toBeInTheDocument()
|
|
49
106
|
})
|
|
50
107
|
|
|
51
108
|
it('should not show filtered indicator when counts match', () => {
|
|
52
|
-
|
|
53
|
-
|
|
109
|
+
render(
|
|
110
|
+
<RealPagination
|
|
111
|
+
totalCount={100}
|
|
112
|
+
pageSize={25}
|
|
113
|
+
renderedCount={25}
|
|
114
|
+
totalFilteredCount={100}
|
|
115
|
+
/>
|
|
116
|
+
)
|
|
54
117
|
|
|
55
118
|
expect(screen.queryByText(/filtered items/)).not.toBeInTheDocument()
|
|
56
119
|
})
|
|
@@ -354,6 +354,43 @@ describe('usePagination', () => {
|
|
|
354
354
|
expect(range.start).not.toBeGreaterThan(range.end)
|
|
355
355
|
})
|
|
356
356
|
|
|
357
|
+
it('should end the range at the last row rendered, not the last row of a page size', () => {
|
|
358
|
+
// The consuming table fetched 50 while declaring 20 (bd startsim-b9v6p).
|
|
359
|
+
const { result } = renderHook(() =>
|
|
360
|
+
usePagination({ totalCount: 5485, initialPageSize: 20, renderedCount: 50 })
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
expect(result.current.getDisplayRange()).toEqual({ start: 1, end: 50 })
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('should span the whole corpus when the table rendered all of it', () => {
|
|
367
|
+
const { result } = renderHook(() =>
|
|
368
|
+
usePagination({ totalCount: 68, initialPageSize: 20, renderedCount: 68 })
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
expect(result.current.getDisplayRange()).toEqual({ start: 1, end: 68 })
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('should never run the range past the reported total', () => {
|
|
375
|
+
// A caller whose total is below what it rendered is its own defect; the
|
|
376
|
+
// range still has to read forwards.
|
|
377
|
+
const { result } = renderHook(() =>
|
|
378
|
+
usePagination({ totalCount: 50, initialPageSize: 20, renderedCount: 68 })
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
expect(result.current.getDisplayRange()).toEqual({ start: 1, end: 50 })
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('should fall back to the page size while the body is empty', () => {
|
|
385
|
+
// A refetch that clears `data` between pages must not flash "No items"
|
|
386
|
+
// over a table that has thousands.
|
|
387
|
+
const { result } = renderHook(() =>
|
|
388
|
+
usePagination({ ...defaultProps, renderedCount: 0 })
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
expect(result.current.getDisplayRange()).toEqual({ start: 1, end: 25 })
|
|
392
|
+
})
|
|
393
|
+
|
|
357
394
|
it('should report an empty range when the page sits past the last row', () => {
|
|
358
395
|
// Server-side counts shrink under a page that is already open (rule 8:
|
|
359
396
|
// the count comes from the server, the client never recounts).
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Showing X-Y of N" must describe the rows that are actually on screen
|
|
3
|
+
* (bd startsim-b9v6p).
|
|
4
|
+
*
|
|
5
|
+
* MEASURED LIVE on the marketing-agents tenant: every content view said
|
|
6
|
+
* "Showing 1-20 of N" no matter how many rows the body held — 50 for a
|
|
7
|
+
* server-paged view, 68 for one that renders its whole corpus. The end of the
|
|
8
|
+
* range was arithmetic over the paginator's OWN page size, a number the table
|
|
9
|
+
* body never consults, because `UnifiedTable` renders exactly the rows its
|
|
10
|
+
* caller hands it and never slices them.
|
|
11
|
+
*
|
|
12
|
+
* These tests drive the REAL `usePagination` through a real `<UnifiedTable>`,
|
|
13
|
+
* which is the only way to prove the wiring: `TablePagination`'s own suite
|
|
14
|
+
* mocks `getDisplayRange`, so it asserts the label renders whatever the mock
|
|
15
|
+
* said and can never see the range being wrong.
|
|
16
|
+
*
|
|
17
|
+
* THEY ASSERT AN INVARIANT, NOT A ROW COUNT. Every expectation below is
|
|
18
|
+
* "the label counts the rows that are on screen", read from the DOM both
|
|
19
|
+
* times. That is deliberate: whether `UnifiedTable` should also SLICE its data
|
|
20
|
+
* is a live question elsewhere, and pinning "50 rows render" here would turn
|
|
21
|
+
* this suite into an obstacle to that decision while proving nothing extra —
|
|
22
|
+
* the invariant is already red against the old arithmetic, which computed the
|
|
23
|
+
* end of the range from a page size the body never consulted.
|
|
24
|
+
*/
|
|
25
|
+
import { render } from '@testing-library/react'
|
|
26
|
+
import { UnifiedTable } from '../../index'
|
|
27
|
+
import { ColumnConfig } from '../../types'
|
|
28
|
+
|
|
29
|
+
interface Row {
|
|
30
|
+
id: string
|
|
31
|
+
name: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const columns: ColumnConfig<Row>[] = [{ id: 'name', header: 'Name', accessorKey: 'name' }]
|
|
35
|
+
|
|
36
|
+
/** `count` rows whose ids start at `startAt`, as a server page would arrive. */
|
|
37
|
+
function rows(count: number, startAt = 1): Row[] {
|
|
38
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
39
|
+
id: String(startAt + i),
|
|
40
|
+
name: `Row ${startAt + i}`,
|
|
41
|
+
}))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The "Showing …" line, read out of the DOM the way a user reads it. */
|
|
45
|
+
function rangeLabel(container: HTMLElement): string {
|
|
46
|
+
const matches = Array.from(container.querySelectorAll('div')).filter((d) =>
|
|
47
|
+
/^Showing \d+-\d+ of \d+$/.test((d.textContent ?? '').trim()),
|
|
48
|
+
)
|
|
49
|
+
if (matches.length === 0) throw new Error('no "Showing X-Y of N" label rendered')
|
|
50
|
+
// Deepest match: an ancestor carrying only this text is the same label.
|
|
51
|
+
return (matches[matches.length - 1].textContent ?? '').trim()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How many body rows the table actually put on screen. */
|
|
55
|
+
function renderedRowCount(container: HTMLElement): number {
|
|
56
|
+
return container.querySelectorAll('tbody tr').length
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Asserts the one thing the label owes the user: its span is the rows in the
|
|
61
|
+
* body. Both numbers are read out of the same DOM, so the assertion holds
|
|
62
|
+
* whatever the table decides to render.
|
|
63
|
+
*/
|
|
64
|
+
function expectRangeToCountTheBody(container: HTMLElement, opts: { from: number; of: number }) {
|
|
65
|
+
const rows = renderedRowCount(container)
|
|
66
|
+
expect(rows).toBeGreaterThan(0)
|
|
67
|
+
expect(rangeLabel(container)).toBe(
|
|
68
|
+
`Showing ${opts.from}-${opts.from + rows - 1} of ${opts.of}`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('pagination range vs the rows on screen', () => {
|
|
73
|
+
it('counts a server page of 50 as 50, not as the declared page size', () => {
|
|
74
|
+
// news_item on the marketing-agents tenant: 5485 rows, 50 fetched per page,
|
|
75
|
+
// a paginationConfig that declares 20. The body holds 50.
|
|
76
|
+
const { container } = render(
|
|
77
|
+
<UnifiedTable<Row>
|
|
78
|
+
data={rows(50)}
|
|
79
|
+
columns={columns}
|
|
80
|
+
tableId="server-paged"
|
|
81
|
+
getRowId={(r) => r.id}
|
|
82
|
+
pagination={{
|
|
83
|
+
enabled: true,
|
|
84
|
+
pageSize: 20,
|
|
85
|
+
totalCount: 5485,
|
|
86
|
+
currentPage: 1,
|
|
87
|
+
serverSide: true,
|
|
88
|
+
onPageChange: jest.fn(),
|
|
89
|
+
}}
|
|
90
|
+
/>,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// Pre-fix this read "Showing 1-20 of 5485" over a body of 50 rows.
|
|
94
|
+
expectRangeToCountTheBody(container, { from: 1, of: 5485 })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('counts a fully client-rendered table as all of it', () => {
|
|
98
|
+
// topic: the page asks for every row, so all 68 render at once and the
|
|
99
|
+
// range is the whole corpus.
|
|
100
|
+
const { container } = render(
|
|
101
|
+
<UnifiedTable<Row>
|
|
102
|
+
data={rows(68)}
|
|
103
|
+
columns={columns}
|
|
104
|
+
tableId="client-rendered"
|
|
105
|
+
getRowId={(r) => r.id}
|
|
106
|
+
pagination={{
|
|
107
|
+
enabled: true,
|
|
108
|
+
pageSize: 20,
|
|
109
|
+
totalCount: 68,
|
|
110
|
+
currentPage: 1,
|
|
111
|
+
serverSide: false,
|
|
112
|
+
}}
|
|
113
|
+
/>,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// Pre-fix this read "Showing 1-20 of 68" over a body of 68 rows.
|
|
117
|
+
expectRangeToCountTheBody(container, { from: 1, of: 68 })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('leaves a table whose page size matches its fetch exactly as it was', () => {
|
|
121
|
+
// The overwhelming majority of the fleet: 25 declared, 25 rendered, page 3.
|
|
122
|
+
const { container } = render(
|
|
123
|
+
<UnifiedTable<Row>
|
|
124
|
+
data={rows(25, 51)}
|
|
125
|
+
columns={columns}
|
|
126
|
+
tableId="well-behaved"
|
|
127
|
+
getRowId={(r) => r.id}
|
|
128
|
+
pagination={{
|
|
129
|
+
enabled: true,
|
|
130
|
+
pageSize: 25,
|
|
131
|
+
totalCount: 100,
|
|
132
|
+
currentPage: 3,
|
|
133
|
+
serverSide: true,
|
|
134
|
+
onPageChange: jest.fn(),
|
|
135
|
+
}}
|
|
136
|
+
/>,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
expectRangeToCountTheBody(container, { from: 51, of: 100 })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('stops a short last page at the last row', () => {
|
|
143
|
+
const { container } = render(
|
|
144
|
+
<UnifiedTable<Row>
|
|
145
|
+
data={rows(15, 76)}
|
|
146
|
+
columns={columns}
|
|
147
|
+
tableId="short-last-page"
|
|
148
|
+
getRowId={(r) => r.id}
|
|
149
|
+
pagination={{
|
|
150
|
+
enabled: true,
|
|
151
|
+
pageSize: 25,
|
|
152
|
+
totalCount: 90,
|
|
153
|
+
currentPage: 4,
|
|
154
|
+
serverSide: true,
|
|
155
|
+
onPageChange: jest.fn(),
|
|
156
|
+
}}
|
|
157
|
+
/>,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
expectRangeToCountTheBody(container, { from: 76, of: 90 })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('says "No items" rather than a range when the body is empty', () => {
|
|
164
|
+
const { container } = render(
|
|
165
|
+
<UnifiedTable<Row>
|
|
166
|
+
data={[]}
|
|
167
|
+
columns={columns}
|
|
168
|
+
tableId="empty"
|
|
169
|
+
getRowId={(r) => r.id}
|
|
170
|
+
pagination={{
|
|
171
|
+
enabled: true,
|
|
172
|
+
pageSize: 20,
|
|
173
|
+
totalCount: 0,
|
|
174
|
+
currentPage: 1,
|
|
175
|
+
serverSide: true,
|
|
176
|
+
}}
|
|
177
|
+
/>,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
expect(() => rangeLabel(container)).toThrow()
|
|
181
|
+
expect(container.textContent).toContain('No items')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -8,6 +8,15 @@ interface UsePaginationProps {
|
|
|
8
8
|
initialPage?: number
|
|
9
9
|
serverSide?: boolean
|
|
10
10
|
onPageChange?: (page: number) => void
|
|
11
|
+
/**
|
|
12
|
+
* How many rows the table actually put on screen for this page.
|
|
13
|
+
*
|
|
14
|
+
* Optional because the hook is public API and a caller may drive it without a
|
|
15
|
+
* table attached; when it is absent the page size is the only number
|
|
16
|
+
* available. When it is present it WINS for the width of the display range,
|
|
17
|
+
* because the body is the thing the label is describing.
|
|
18
|
+
*/
|
|
19
|
+
renderedCount?: number
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
export function usePagination({
|
|
@@ -16,6 +25,7 @@ export function usePagination({
|
|
|
16
25
|
initialPage = 1,
|
|
17
26
|
serverSide = false,
|
|
18
27
|
onPageChange,
|
|
28
|
+
renderedCount,
|
|
19
29
|
}: UsePaginationProps): UsePaginationReturn {
|
|
20
30
|
const [currentPage, setCurrentPage] = useState(initialPage)
|
|
21
31
|
const [pageSize, setPageSize] = useState(initialPageSize)
|
|
@@ -102,18 +112,40 @@ export function usePagination({
|
|
|
102
112
|
* no span to describe. `totalCount` is whatever the server reported, so it can
|
|
103
113
|
* legitimately be 0, or drop below the page the user already has open — both
|
|
104
114
|
* cases used to produce a backwards range like `1-0`.
|
|
115
|
+
*
|
|
116
|
+
* The WIDTH of the span comes from `renderedCount` — the rows the table
|
|
117
|
+
* actually drew — and not from `pageSize`. `UnifiedTable` renders exactly the
|
|
118
|
+
* rows it is handed and never slices them, so a view that fetches 50 per page,
|
|
119
|
+
* or fetches its whole corpus at once, holds a different number of rows than
|
|
120
|
+
* the page size its caller declared. Deriving the end from `pageSize` made the
|
|
121
|
+
* label read "Showing 1-20" over a body of 50 or 68 rows on every table in the
|
|
122
|
+
* fleet (bd startsim-b9v6p).
|
|
123
|
+
*
|
|
124
|
+
* The OFFSET still comes from `currentPage * pageSize`: how many rows precede
|
|
125
|
+
* this page is a fact only the caller's page size can supply, and no count of
|
|
126
|
+
* what is on screen can recover it. A caller that declares one page size and
|
|
127
|
+
* fetches another therefore still gets a wrong `start` past page 1 — but its
|
|
128
|
+
* pager is already wrong in exactly the same way, and the fix for that is for
|
|
129
|
+
* the caller to declare the size it uses.
|
|
105
130
|
*/
|
|
106
131
|
const getDisplayRange = useCallback(() => {
|
|
107
132
|
if (totalCount <= 0) return { start: 0, end: 0 }
|
|
108
133
|
|
|
134
|
+
// An empty body is left to the page-size arithmetic rather than reported as
|
|
135
|
+
// an empty range: a refetch that clears `data` between pages would otherwise
|
|
136
|
+
// flash "No items" over a table that has thousands.
|
|
137
|
+
const rowsOnScreen = renderedCount != null && renderedCount > 0 ? renderedCount : pageSize
|
|
138
|
+
|
|
109
139
|
const start = (currentPage - 1) * pageSize + 1
|
|
110
|
-
|
|
140
|
+
// Clamped to `totalCount`: a caller reporting a total BELOW what it rendered
|
|
141
|
+
// is a different defect, and "1-68 of 50" would only make it unreadable.
|
|
142
|
+
const end = Math.min(start + rowsOnScreen - 1, totalCount)
|
|
111
143
|
|
|
112
144
|
// The page begins past the last row: nothing is shown here.
|
|
113
145
|
if (start > end) return { start: 0, end: 0 }
|
|
114
146
|
|
|
115
147
|
return { start, end }
|
|
116
|
-
}, [currentPage, pageSize, totalCount])
|
|
148
|
+
}, [currentPage, pageSize, totalCount, renderedCount])
|
|
117
149
|
|
|
118
150
|
return {
|
|
119
151
|
currentPage,
|