@remit/web-client 0.0.233 → 0.0.235
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 +1 -1
- package/src/components/calendar/AgendaStrip.tsx +17 -0
- package/src/components/calendar/AgendaView.tsx +27 -8
- package/src/components/calendar/agenda-address.render.test.ts +124 -20
- package/src/components/config-import/config-import.stories.tsx +18 -0
- package/src/components/config-import/steps.viewport.test.ts +216 -0
- package/src/hooks/calendar/agenda-window.test.ts +82 -9
- package/src/hooks/calendar/agenda-window.ts +50 -10
- package/src/hooks/calendar/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.235",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -63,6 +63,15 @@ export interface AgendaStripProps {
|
|
|
63
63
|
onGoToDate: (date: string) => void;
|
|
64
64
|
onReachStart: () => void;
|
|
65
65
|
onReachEnd: () => void;
|
|
66
|
+
/**
|
|
67
|
+
* The run has grown as far as scrolling takes it. The strip says so at that
|
|
68
|
+
* end and offers the next stretch, so a diary too sparse to fill the pane
|
|
69
|
+
* cannot fetch its way across years nobody asked for.
|
|
70
|
+
*/
|
|
71
|
+
atStartCap?: boolean;
|
|
72
|
+
atEndCap?: boolean;
|
|
73
|
+
onLoadEarlier?: () => void;
|
|
74
|
+
onLoadLater?: () => void;
|
|
66
75
|
/** The day under the sticky header, which the address follows. */
|
|
67
76
|
onVisibleDayChange: (date: string) => void;
|
|
68
77
|
}
|
|
@@ -86,6 +95,10 @@ export function AgendaStrip({
|
|
|
86
95
|
onGoToDate,
|
|
87
96
|
onReachStart,
|
|
88
97
|
onReachEnd,
|
|
98
|
+
atStartCap,
|
|
99
|
+
atEndCap,
|
|
100
|
+
onLoadEarlier,
|
|
101
|
+
onLoadLater,
|
|
89
102
|
onVisibleDayChange,
|
|
90
103
|
}: AgendaStripProps) {
|
|
91
104
|
const nextUp = useMemo(() => readNextUp(days, now), [days, now]);
|
|
@@ -126,6 +139,10 @@ export function AgendaStrip({
|
|
|
126
139
|
onZoomDay={onZoomDay}
|
|
127
140
|
onReachStart={onReachStart}
|
|
128
141
|
onReachEnd={onReachEnd}
|
|
142
|
+
atStartCap={atStartCap}
|
|
143
|
+
atEndCap={atEndCap}
|
|
144
|
+
onLoadEarlier={onLoadEarlier}
|
|
145
|
+
onLoadLater={onLoadLater}
|
|
129
146
|
onVisibleDayChange={onVisibleDayChange}
|
|
130
147
|
todayLead={
|
|
131
148
|
settledToday ? (
|
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
extendRangeEnd,
|
|
16
16
|
extendRangeStart,
|
|
17
17
|
freeStretchesByDate,
|
|
18
|
+
liftRangeCeiling,
|
|
19
|
+
liftRangeFloor,
|
|
18
20
|
rangeAround,
|
|
21
|
+
rangeAtCeiling,
|
|
22
|
+
rangeAtFloor,
|
|
19
23
|
rangeCovering,
|
|
20
24
|
readCalendarInstanceId,
|
|
21
25
|
useCalendarEventWeeks,
|
|
@@ -44,16 +48,22 @@ import {
|
|
|
44
48
|
* you drop into and leave" means in practice.
|
|
45
49
|
*
|
|
46
50
|
* The days it holds are fetched a week at a time, on the grid's own cache keys.
|
|
47
|
-
* That is what
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
51
|
+
* That is what lets the range grow at all — a single read may not cover more
|
|
52
|
+
* than a year, and one request that widened every time the reader reached an
|
|
53
|
+
* end would eventually be refused and replace the strip with the refusal — and
|
|
54
|
+
* it is what makes the two zooms share everything they have already read.
|
|
55
|
+
*
|
|
56
|
+
* It grows on the reader's scroll and on nothing else, a week at a time, and
|
|
57
|
+
* only as far as a year either way. Past that it says so and offers the next
|
|
58
|
+
* stretch: a sparse diary draws shorter than the distance the strip fetches at,
|
|
59
|
+
* so an end measured off the content alone is reached on the first layout pass
|
|
60
|
+
* and never stops being reached.
|
|
52
61
|
*
|
|
53
62
|
* Scrolling writes the day back to the address, debounced and by `replace`.
|
|
54
63
|
* Debounced because a flick past a fortnight is one move rather than fourteen,
|
|
55
64
|
* and `replace` because scrolling is not somewhere a reader went — Back belongs
|
|
56
|
-
* to the screen they came from, not to the row they scrolled past.
|
|
65
|
+
* to the screen they came from, not to the row they scrolled past. Growing the
|
|
66
|
+
* range writes nothing: days arriving at an end is not the reader moving.
|
|
57
67
|
*/
|
|
58
68
|
|
|
59
69
|
/** Long enough that a flick lands before the address moves. */
|
|
@@ -183,6 +193,11 @@ export function AgendaView({ density, onPickSlot }: AgendaViewProps) {
|
|
|
183
193
|
[openEvent],
|
|
184
194
|
);
|
|
185
195
|
|
|
196
|
+
const growStart = useCallback(() => setRange(extendRangeStart), []);
|
|
197
|
+
const growEnd = useCallback(() => setRange(extendRangeEnd), []);
|
|
198
|
+
const loadEarlier = useCallback(() => setRange(liftRangeFloor), []);
|
|
199
|
+
const loadLater = useCallback(() => setRange(liftRangeCeiling), []);
|
|
200
|
+
|
|
186
201
|
/** Whichever read was refused is the one a retry re-sends. */
|
|
187
202
|
const retry = useCallback(() => {
|
|
188
203
|
events.refetch();
|
|
@@ -214,8 +229,12 @@ export function AgendaView({ density, onPickSlot }: AgendaViewProps) {
|
|
|
214
229
|
onPickSlot={onPickSlot}
|
|
215
230
|
onZoomDay={zoomToDay}
|
|
216
231
|
onGoToDate={goToDate}
|
|
217
|
-
onReachStart={
|
|
218
|
-
onReachEnd={
|
|
232
|
+
onReachStart={growStart}
|
|
233
|
+
onReachEnd={growEnd}
|
|
234
|
+
atStartCap={rangeAtFloor(range)}
|
|
235
|
+
atEndCap={rangeAtCeiling(range)}
|
|
236
|
+
onLoadEarlier={loadEarlier}
|
|
237
|
+
onLoadLater={loadLater}
|
|
219
238
|
onVisibleDayChange={setVisibleDate}
|
|
220
239
|
/>
|
|
221
240
|
);
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The strip and the address, bound together.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* more
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* history entries.
|
|
4
|
+
* Four claims, and every one of them is about the URL rather than about pixels:
|
|
5
|
+
* the strip opens on the day the path names, the reader reaching an end asks
|
|
6
|
+
* for a week more rather than replacing the days on screen, a strip nobody
|
|
7
|
+
* scrolled asks for nothing at all, and the day the reader scrolls to is
|
|
8
|
+
* written by `replace` — scrolling is not somewhere they went, so Back belongs
|
|
9
|
+
* to the screen they arrived from and not to every row they passed. The last
|
|
10
|
+
* one is the reason this suite drives the real router instead of a callback: a
|
|
11
|
+
* `push` here would bury the way out under a fortnight of history entries.
|
|
12
12
|
*/
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
14
|
import { afterEach, describe, it } from "node:test";
|
|
@@ -201,15 +201,49 @@ const mount = async (path = `/calendar/agenda/${DATE}`): Promise<Mounted> => {
|
|
|
201
201
|
};
|
|
202
202
|
};
|
|
203
203
|
|
|
204
|
-
/**
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
/**
|
|
205
|
+
* The one element the strip scrolls. jsdom has no layout, so it is given the
|
|
206
|
+
* one part of it these cases turn on: an offset that moves and stays where it
|
|
207
|
+
* was put. Everything else about the strip's geometry is zero, which is the
|
|
208
|
+
* sparse diary this suite is about — every end within reach of every other.
|
|
209
|
+
*/
|
|
210
|
+
const stubbed = new WeakSet<Element>();
|
|
211
|
+
|
|
212
|
+
const scroller = (dom: DomHarness): HTMLElement => {
|
|
213
|
+
const found = dom.query<HTMLElement>(".overflow-y-auto");
|
|
207
214
|
if (!found) throw new Error("the strip rendered no scroller");
|
|
215
|
+
if (stubbed.has(found)) return found;
|
|
216
|
+
let top = 0;
|
|
217
|
+
Object.defineProperty(found, "scrollTop", {
|
|
218
|
+
configurable: true,
|
|
219
|
+
get: () => top,
|
|
220
|
+
set: (next: number) => {
|
|
221
|
+
top = Math.max(0, next);
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
stubbed.add(found);
|
|
208
225
|
return found;
|
|
209
226
|
};
|
|
210
227
|
|
|
211
|
-
|
|
212
|
-
|
|
228
|
+
/** A reader scrolling: the gesture, where it took the strip, then the event. */
|
|
229
|
+
const scrollBy = (dom: DomHarness, delta: number) => {
|
|
230
|
+
const element = scroller(dom);
|
|
231
|
+
dom.dispatch(element, new Event("wheel", { bubbles: true }));
|
|
232
|
+
element.scrollTop += delta;
|
|
233
|
+
dom.dispatch(element, new Event("scroll", { bubbles: false }));
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const scrollDown = (dom: DomHarness) => scrollBy(dom, 200);
|
|
237
|
+
const scrollUp = (dom: DomHarness) => scrollBy(dom, -400);
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The same movement with nobody behind it: a font swapping under a month of
|
|
241
|
+
* rows, a pane resized, the offset taken back after days were prepended.
|
|
242
|
+
*/
|
|
243
|
+
const settle = (dom: DomHarness) => {
|
|
244
|
+
const element = scroller(dom);
|
|
245
|
+
element.scrollTop += 120;
|
|
246
|
+
dom.dispatch(element, new Event("scroll", { bubbles: false }));
|
|
213
247
|
};
|
|
214
248
|
|
|
215
249
|
describe("the day the address names", () => {
|
|
@@ -238,21 +272,35 @@ describe("reaching an end of the strip", () => {
|
|
|
238
272
|
it("asks for more days without giving up the ones on screen", async () => {
|
|
239
273
|
const { dom } = await mount();
|
|
240
274
|
const before = dom.text();
|
|
241
|
-
assert.doesNotMatch(before, /
|
|
275
|
+
assert.doesNotMatch(before, /11 Jul/);
|
|
242
276
|
|
|
243
|
-
|
|
277
|
+
scrollDown(dom);
|
|
244
278
|
await dom.flush();
|
|
245
279
|
|
|
246
280
|
const after = dom.text();
|
|
247
|
-
assert.match(after, /
|
|
281
|
+
assert.match(after, /11 Jul/, "the days past the window did not arrive");
|
|
248
282
|
assert.match(after, /9 Jun/, "the days it opened with were thrown away");
|
|
249
283
|
});
|
|
250
284
|
|
|
285
|
+
it("grows the end the reader went to, a week at a time, and only that one", async () => {
|
|
286
|
+
const { dom } = await mount();
|
|
287
|
+
scrollDown(dom);
|
|
288
|
+
await dom.flush();
|
|
289
|
+
assert.match(dom.text(), /11 Jul/);
|
|
290
|
+
assert.doesNotMatch(dom.text(), /24 May/, "the end nobody went to grew");
|
|
291
|
+
|
|
292
|
+
scrollUp(dom);
|
|
293
|
+
await dom.flush();
|
|
294
|
+
assert.match(dom.text(), /24 May/, "the days behind never arrived");
|
|
295
|
+
});
|
|
296
|
+
|
|
251
297
|
it("adds week keys rather than widening the one it holds", async () => {
|
|
252
298
|
const { dom } = await mount();
|
|
253
299
|
const opening = eventWindows(dom).map((window) => window.from);
|
|
254
300
|
|
|
255
|
-
|
|
301
|
+
scrollDown(dom);
|
|
302
|
+
await dom.flush();
|
|
303
|
+
scrollUp(dom);
|
|
256
304
|
await dom.flush();
|
|
257
305
|
|
|
258
306
|
const after = eventWindows(dom).map((window) => window.from);
|
|
@@ -269,7 +317,7 @@ describe("reaching an end of the strip", () => {
|
|
|
269
317
|
it("never grows a single read past what the server will answer", async () => {
|
|
270
318
|
const { dom } = await mount();
|
|
271
319
|
for (let reach = 0; reach < 6; reach += 1) {
|
|
272
|
-
|
|
320
|
+
scrollDown(dom);
|
|
273
321
|
await dom.flush();
|
|
274
322
|
}
|
|
275
323
|
for (const window of eventWindows(dom)) {
|
|
@@ -280,10 +328,50 @@ describe("reaching an end of the strip", () => {
|
|
|
280
328
|
});
|
|
281
329
|
});
|
|
282
330
|
|
|
331
|
+
/**
|
|
332
|
+
* The bug this suite exists to keep out. A diary with two events in it draws
|
|
333
|
+
* shorter than the distance the strip fetches at, so both ends are reached from
|
|
334
|
+
* the first layout pass, and every extension raised another scroll event to
|
|
335
|
+
* reach them again. Left to run it walked a five-week window out to 2032 and
|
|
336
|
+
* wrote every day of the way into the path.
|
|
337
|
+
*/
|
|
338
|
+
describe("a strip nobody has scrolled", () => {
|
|
339
|
+
it("asks for the weeks it opened with and no others", async () => {
|
|
340
|
+
const { dom } = await mount();
|
|
341
|
+
const opening = eventWindows(dom)
|
|
342
|
+
.map((window) => window.from)
|
|
343
|
+
.sort();
|
|
344
|
+
|
|
345
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
346
|
+
settle(dom);
|
|
347
|
+
await dom.flush();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
assert.deepEqual(
|
|
351
|
+
eventWindows(dom)
|
|
352
|
+
.map((window) => window.from)
|
|
353
|
+
.sort(),
|
|
354
|
+
opening,
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("leaves the address on the day it was opened with", async () => {
|
|
359
|
+
const { dom, router } = await mount();
|
|
360
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
361
|
+
settle(dom);
|
|
362
|
+
await dom.flush();
|
|
363
|
+
}
|
|
364
|
+
await dom.wait(600);
|
|
365
|
+
await dom.flush();
|
|
366
|
+
|
|
367
|
+
assert.equal(router.state.location.pathname, `/calendar/agenda/${DATE}`);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
283
371
|
describe("the day the reader scrolled to", () => {
|
|
284
372
|
it("is written to the path, so a reload comes back to it", async () => {
|
|
285
373
|
const { dom, router } = await mount();
|
|
286
|
-
|
|
374
|
+
scrollDown(dom);
|
|
287
375
|
await dom.wait(600);
|
|
288
376
|
await dom.flush();
|
|
289
377
|
|
|
@@ -297,13 +385,29 @@ describe("the day the reader scrolled to", () => {
|
|
|
297
385
|
|
|
298
386
|
it("replaces rather than pushes, so Back is still the way out", async () => {
|
|
299
387
|
const mounted = await mount();
|
|
300
|
-
|
|
388
|
+
scrollDown(mounted.dom);
|
|
301
389
|
await mounted.dom.wait(600);
|
|
302
390
|
await mounted.dom.flush();
|
|
303
391
|
|
|
304
392
|
assert.ok(mounted.replaces > 0, "the address was never written");
|
|
305
393
|
assert.equal(mounted.pushes, 0, "scrolling stacked up history entries");
|
|
306
394
|
});
|
|
395
|
+
|
|
396
|
+
it("stays there while the days it asked for arrive", async () => {
|
|
397
|
+
const { dom, router } = await mount();
|
|
398
|
+
scrollDown(dom);
|
|
399
|
+
await dom.wait(600);
|
|
400
|
+
await dom.flush();
|
|
401
|
+
const landed = router.state.location.pathname;
|
|
402
|
+
|
|
403
|
+
// The prepend takes the scroll offset back, which is another scroll event.
|
|
404
|
+
// It is not the reader moving, so it writes nothing.
|
|
405
|
+
settle(dom);
|
|
406
|
+
await dom.wait(600);
|
|
407
|
+
await dom.flush();
|
|
408
|
+
|
|
409
|
+
assert.equal(router.state.location.pathname, landed);
|
|
410
|
+
});
|
|
307
411
|
});
|
|
308
412
|
|
|
309
413
|
describe("dropping into the grid and back out", () => {
|
|
@@ -233,6 +233,24 @@ export const DryRunReportPhone: Story = {
|
|
|
233
233
|
),
|
|
234
234
|
};
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* A household's worth of rules and senders, on the 1512×827 laptop where this
|
|
238
|
+
* report was found running off the bottom of the screen with "Import n changes"
|
|
239
|
+
* unreachable below it (#1021). The report scrolls inside the card; the
|
|
240
|
+
* verdict summary at the top and the import button at the bottom both stay put.
|
|
241
|
+
*/
|
|
242
|
+
export const DryRunReportLongShortViewport: Story = {
|
|
243
|
+
globals: { viewport: { value: "laptopShort" } },
|
|
244
|
+
render: () => (
|
|
245
|
+
<StepDryRunReport
|
|
246
|
+
sections={groupReportSections(
|
|
247
|
+
Array.from({ length: 6 }, () => dryRunReport.items).flat(),
|
|
248
|
+
)}
|
|
249
|
+
fileName={CONFIG_FILE_NAME}
|
|
250
|
+
/>
|
|
251
|
+
),
|
|
252
|
+
};
|
|
253
|
+
|
|
236
254
|
const HELD =
|
|
237
255
|
"2 accounts, 14 rules and 31 flagged senders are set up here already.";
|
|
238
256
|
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A report longer than the screen still leaves the reader somewhere to press.
|
|
3
|
+
*
|
|
4
|
+
* On a 1512×827 laptop the dry run of a real configuration drew taller than the
|
|
5
|
+
* viewport. The wizard sat in a fixed box with nothing scrolling, so "Import n
|
|
6
|
+
* changes" was rendered below the fold with no way to reach it and the import
|
|
7
|
+
* could not be finished at all (#1021).
|
|
8
|
+
*
|
|
9
|
+
* The shape that fixes it is structural, and structure is what this file holds:
|
|
10
|
+
* the growable part of a step lives in its own scroll box, the title and the
|
|
11
|
+
* footer actions sit outside that box, and the shell is capped to the viewport
|
|
12
|
+
* so nothing can push the footer off it. jsdom has no layout engine, so the
|
|
13
|
+
* pixels are pinned by the "long report, short viewport" Storybook stories
|
|
14
|
+
* instead — what is asserted here is the arrangement those stories depend on,
|
|
15
|
+
* which is the part a refactor silently takes away.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { afterEach, describe, it } from "node:test";
|
|
20
|
+
import { createElement } from "react";
|
|
21
|
+
import type { ReportSection } from "../../lib/config-import";
|
|
22
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
23
|
+
import type { ImportedAccount } from "./steps";
|
|
24
|
+
import { StepCredentialsOverview, StepDryRunReport } from "./steps";
|
|
25
|
+
|
|
26
|
+
let harness: DomHarness | undefined;
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
harness?.close();
|
|
30
|
+
harness = undefined;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const LONG_REPORT: ReportSection[] = [
|
|
34
|
+
{
|
|
35
|
+
id: "accounts",
|
|
36
|
+
title: "Accounts",
|
|
37
|
+
entries: Array.from({ length: 40 }, (_, index) => ({
|
|
38
|
+
id: `accounts-${index}`,
|
|
39
|
+
label: `mailbox-${index}@example.test`,
|
|
40
|
+
verdict: "created" as const,
|
|
41
|
+
})),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "filters",
|
|
45
|
+
title: "Rules & filters",
|
|
46
|
+
entries: Array.from({ length: 40 }, (_, index) => ({
|
|
47
|
+
id: `filters-${index}`,
|
|
48
|
+
label: `Rule ${index}`,
|
|
49
|
+
verdict: "skipped" as const,
|
|
50
|
+
reason: "The folder this rule files into is not here yet.",
|
|
51
|
+
})),
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const MANY_ACCOUNTS: ImportedAccount[] = Array.from(
|
|
56
|
+
{ length: 30 },
|
|
57
|
+
(_, index) => ({
|
|
58
|
+
accountId: `acct-${index}`,
|
|
59
|
+
address: `mailbox-${index}@example.test`,
|
|
60
|
+
displayName: `Mailbox ${index}`,
|
|
61
|
+
connector: "imap" as const,
|
|
62
|
+
server: "imap.example.test",
|
|
63
|
+
state: "needed" as const,
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const classes = (element: Element): string[] =>
|
|
68
|
+
(element.getAttribute("class") ?? "").split(/\s+/).filter(Boolean);
|
|
69
|
+
|
|
70
|
+
const shellOf = (mounted: DomHarness): Element => {
|
|
71
|
+
const body = mounted.query("[data-testid=wizard-body]");
|
|
72
|
+
assert.ok(body, "the step renders a wizard body region");
|
|
73
|
+
const shell = body.closest("div.h-dvh");
|
|
74
|
+
assert.ok(shell, "the wizard body sits inside the shell root");
|
|
75
|
+
return shell;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const FOCUSABLE =
|
|
79
|
+
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
80
|
+
|
|
81
|
+
describe("a wizard step longer than the viewport (#1021)", () => {
|
|
82
|
+
it("puts the whole report inside the scroll box and the footer outside it", () => {
|
|
83
|
+
harness = createDomHarness({ viewportWidth: 1512 });
|
|
84
|
+
harness.render(
|
|
85
|
+
createElement(StepDryRunReport, {
|
|
86
|
+
sections: LONG_REPORT,
|
|
87
|
+
fileName: "reader-config.example.json",
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const body = harness.query("[data-testid=wizard-body]");
|
|
92
|
+
const footer = harness.query("[data-testid=wizard-footer]");
|
|
93
|
+
assert.ok(body && footer);
|
|
94
|
+
|
|
95
|
+
// The rail above the card renders a list item per step, so the count is
|
|
96
|
+
// the assertion: all 80 report rows are inside the scroll box, and a row
|
|
97
|
+
// left outside it would show up as a shortfall here.
|
|
98
|
+
assert.equal(
|
|
99
|
+
body.querySelectorAll("li").length,
|
|
100
|
+
80,
|
|
101
|
+
"every report row grows inside the scroll box",
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
assert.ok(
|
|
105
|
+
!body.contains(footer),
|
|
106
|
+
"the footer sits outside the scroll box, so the report cannot scroll it away",
|
|
107
|
+
);
|
|
108
|
+
const primary = harness.byText("button", "Import");
|
|
109
|
+
assert.ok(
|
|
110
|
+
footer.contains(primary),
|
|
111
|
+
"the primary action lives in the footer",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("scrolls the body and never the shell", () => {
|
|
116
|
+
harness = createDomHarness({ viewportWidth: 1512 });
|
|
117
|
+
harness.render(
|
|
118
|
+
createElement(StepDryRunReport, {
|
|
119
|
+
sections: LONG_REPORT,
|
|
120
|
+
fileName: "reader-config.example.json",
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const body = harness.query("[data-testid=wizard-body]");
|
|
125
|
+
assert.ok(body);
|
|
126
|
+
const bodyClasses = classes(body);
|
|
127
|
+
assert.ok(
|
|
128
|
+
bodyClasses.includes("overflow-y-auto"),
|
|
129
|
+
"the body region is the scrolling element",
|
|
130
|
+
);
|
|
131
|
+
assert.ok(
|
|
132
|
+
bodyClasses.includes("min-h-0"),
|
|
133
|
+
"the body region can shrink below its content, or it never scrolls",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const shell = shellOf(harness);
|
|
137
|
+
assert.ok(
|
|
138
|
+
classes(shell).includes("overflow-hidden"),
|
|
139
|
+
"the shell root does not scroll — the footer would ride out of view with it",
|
|
140
|
+
);
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
harness
|
|
143
|
+
.queryAll('[class*="overflow-y-auto"]')
|
|
144
|
+
.map((element) => element.getAttribute("data-testid")),
|
|
145
|
+
["wizard-body"],
|
|
146
|
+
"exactly one region scrolls, and it is the one holding the growable content",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("caps the shell at the viewport rather than growing past it", () => {
|
|
151
|
+
harness = createDomHarness({ viewportWidth: 1512 });
|
|
152
|
+
harness.render(
|
|
153
|
+
createElement(StepDryRunReport, {
|
|
154
|
+
sections: LONG_REPORT,
|
|
155
|
+
fileName: "reader-config.example.json",
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const shellClasses = classes(shellOf(harness));
|
|
160
|
+
assert.ok(
|
|
161
|
+
shellClasses.includes("h-dvh"),
|
|
162
|
+
"the shell is exactly the viewport, so no content length can extend it",
|
|
163
|
+
);
|
|
164
|
+
assert.ok(
|
|
165
|
+
!shellClasses.some((token) => token.startsWith("min-h-")),
|
|
166
|
+
"a minimum height would let the content push the shell past the fold again",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps the footer action last in the tab order", () => {
|
|
171
|
+
harness = createDomHarness({ viewportWidth: 1512 });
|
|
172
|
+
harness.render(
|
|
173
|
+
createElement(StepDryRunReport, {
|
|
174
|
+
sections: LONG_REPORT,
|
|
175
|
+
fileName: "reader-config.example.json",
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const footer = harness.query("[data-testid=wizard-footer]");
|
|
180
|
+
assert.ok(footer);
|
|
181
|
+
const focusable = harness.queryAll(FOCUSABLE);
|
|
182
|
+
assert.ok(focusable.length >= 2, "the step has controls to tab through");
|
|
183
|
+
assert.ok(
|
|
184
|
+
focusable.every((element) => {
|
|
185
|
+
const order = element.getAttribute("tabindex");
|
|
186
|
+
return order === null || Number.parseInt(order, 10) <= 0;
|
|
187
|
+
}),
|
|
188
|
+
"nothing claims a positive tabindex, so DOM order is the tab order",
|
|
189
|
+
);
|
|
190
|
+
const last = focusable[focusable.length - 1];
|
|
191
|
+
assert.ok(
|
|
192
|
+
last && footer.contains(last),
|
|
193
|
+
"tabbing forward through the report ends on the footer action",
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("holds the same shape for a long list of accounts needing credentials", () => {
|
|
198
|
+
harness = createDomHarness({ viewportWidth: 1512 });
|
|
199
|
+
harness.render(
|
|
200
|
+
createElement(StepCredentialsOverview, { accounts: MANY_ACCOUNTS }),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const body = harness.query("[data-testid=wizard-body]");
|
|
204
|
+
const footer = harness.query("[data-testid=wizard-footer]");
|
|
205
|
+
assert.ok(body && footer);
|
|
206
|
+
assert.equal(
|
|
207
|
+
body.querySelectorAll("li").length,
|
|
208
|
+
MANY_ACCOUNTS.length,
|
|
209
|
+
"the account list grows inside the scroll box",
|
|
210
|
+
);
|
|
211
|
+
assert.ok(
|
|
212
|
+
footer.contains(harness.byText("button", "Finish later")),
|
|
213
|
+
"the way on stays pinned however many accounts the file carried",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -13,19 +13,24 @@ import { describe, it } from "node:test";
|
|
|
13
13
|
import { freeStretchesFromSpans } from "@remit/ui";
|
|
14
14
|
import {
|
|
15
15
|
busySpansByDate,
|
|
16
|
+
CAP_WEEKS,
|
|
16
17
|
datesInRange,
|
|
17
18
|
extendRangeEnd,
|
|
18
19
|
extendRangeStart,
|
|
19
20
|
freeStretchesByDate,
|
|
20
21
|
LEAD_IN,
|
|
21
22
|
LEAD_OUT,
|
|
23
|
+
liftRangeCeiling,
|
|
24
|
+
liftRangeFloor,
|
|
22
25
|
PAGE,
|
|
23
26
|
rangeAround,
|
|
27
|
+
rangeAtCeiling,
|
|
28
|
+
rangeAtFloor,
|
|
24
29
|
rangeCovering,
|
|
25
30
|
weekKeyOf,
|
|
26
31
|
weekWindowsOver,
|
|
27
32
|
} from "./agenda-window.js";
|
|
28
|
-
import { calendarWindow, startOfDay } from "./window.js";
|
|
33
|
+
import { addDays, calendarWindow, startOfDay } from "./window.js";
|
|
29
34
|
|
|
30
35
|
const DATE = "2026-06-10";
|
|
31
36
|
|
|
@@ -53,14 +58,8 @@ describe("the days the strip holds", () => {
|
|
|
53
58
|
|
|
54
59
|
it("grows the run at whichever end was reached, keeping the other", () => {
|
|
55
60
|
const range = rangeAround(DATE);
|
|
56
|
-
assert.deepEqual(extendRangeStart(range), {
|
|
57
|
-
|
|
58
|
-
to: range.to,
|
|
59
|
-
});
|
|
60
|
-
assert.deepEqual(extendRangeEnd(range), {
|
|
61
|
-
from: range.from,
|
|
62
|
-
to: "2026-07-18",
|
|
63
|
-
});
|
|
61
|
+
assert.deepEqual(extendRangeStart(range), { ...range, from: "2026-05-24" });
|
|
62
|
+
assert.deepEqual(extendRangeEnd(range), { ...range, to: "2026-07-11" });
|
|
64
63
|
assert.equal(
|
|
65
64
|
datesInRange(extendRangeEnd(range)).length,
|
|
66
65
|
datesInRange(range).length + PAGE,
|
|
@@ -87,6 +86,71 @@ describe("the days the strip holds", () => {
|
|
|
87
86
|
});
|
|
88
87
|
});
|
|
89
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The strip is shorter than the distance it fetches at whenever the diary is
|
|
91
|
+
* sparse, so an end measured off the content is reached on the first layout
|
|
92
|
+
* pass and stays reached however many days arrive. What stops that is the
|
|
93
|
+
* strip only growing on a scroll the reader drove; this is the backstop that
|
|
94
|
+
* makes the runaway it caused — a two-event diary walking out to 2032, with the
|
|
95
|
+
* address following it — arithmetically impossible rather than merely unlikely.
|
|
96
|
+
*/
|
|
97
|
+
describe("how far the run grows before it asks", () => {
|
|
98
|
+
const CAP_DAYS = CAP_WEEKS * 7;
|
|
99
|
+
|
|
100
|
+
/** Far more reaches than a year of weeks needs, so the cap is what stops it. */
|
|
101
|
+
const reachEnd = (range = rangeAround(DATE), times = 200) => {
|
|
102
|
+
let grown = range;
|
|
103
|
+
for (let reach = 0; reach < times; reach += 1)
|
|
104
|
+
grown = extendRangeEnd(grown);
|
|
105
|
+
return grown;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const reachStart = (range = rangeAround(DATE), times = 200) => {
|
|
109
|
+
let grown = range;
|
|
110
|
+
for (let reach = 0; reach < times; reach += 1)
|
|
111
|
+
grown = extendRangeStart(grown);
|
|
112
|
+
return grown;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
it("stops a year either way, however often an end is reached", () => {
|
|
116
|
+
const range = reachEnd(reachStart());
|
|
117
|
+
assert.equal(range.from, addDays(DATE, -CAP_DAYS));
|
|
118
|
+
assert.equal(range.to, addDays(DATE, CAP_DAYS));
|
|
119
|
+
assert.equal(rangeAtFloor(range), true);
|
|
120
|
+
assert.equal(rangeAtCeiling(range), true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("hands back the run it was given at the cap, so nothing refetches", () => {
|
|
124
|
+
const capped = reachEnd();
|
|
125
|
+
assert.equal(extendRangeEnd(capped), capped);
|
|
126
|
+
const behind = reachStart();
|
|
127
|
+
assert.equal(extendRangeStart(behind), behind);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("opens the next year where the reader asked, keeping the days held", () => {
|
|
131
|
+
const capped = reachEnd();
|
|
132
|
+
const more = liftRangeCeiling(capped);
|
|
133
|
+
assert.equal(more.from, capped.from);
|
|
134
|
+
assert.equal(more.to, addDays(capped.to, PAGE));
|
|
135
|
+
assert.equal(rangeAtCeiling(more), false);
|
|
136
|
+
assert.equal(rangeAtFloor(more), false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("does the same behind, without moving the end ahead", () => {
|
|
140
|
+
const capped = reachStart();
|
|
141
|
+
const more = liftRangeFloor(capped);
|
|
142
|
+
assert.equal(more.to, capped.to);
|
|
143
|
+
assert.equal(more.from, addDays(capped.from, -PAGE));
|
|
144
|
+
assert.equal(rangeAtFloor(more), false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("gives a jump its own year, measured from where it landed", () => {
|
|
148
|
+
const jumped = rangeCovering(reachEnd(), "2028-03-01");
|
|
149
|
+
assert.deepEqual(jumped, rangeAround("2028-03-01"));
|
|
150
|
+
assert.equal(rangeAtCeiling(jumped), false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
90
154
|
/**
|
|
91
155
|
* The range is not a window. A read may not cover more than a year
|
|
92
156
|
* (`CALENDAR_MAX_WINDOW_DAYS`), and the strip's range grows every time the
|
|
@@ -105,6 +169,15 @@ describe("the weeks a range is fetched as", () => {
|
|
|
105
169
|
assert.equal(new Set(windows.map((w) => w.from)).size, windows.length);
|
|
106
170
|
});
|
|
107
171
|
|
|
172
|
+
/**
|
|
173
|
+
* What a strip that has just opened issues, and all it issues: the six weeks
|
|
174
|
+
* its opening run falls in. Anything past those is a week the reader has
|
|
175
|
+
* scrolled to.
|
|
176
|
+
*/
|
|
177
|
+
it("is the six weeks the opening run falls in, and no more", () => {
|
|
178
|
+
assert.equal(weekWindowsOver(datesInRange(rangeAround(DATE))).length, 6);
|
|
179
|
+
});
|
|
180
|
+
|
|
108
181
|
it("covers every day the strip holds and nothing before or after", () => {
|
|
109
182
|
const dates = datesInRange(rangeAround(DATE));
|
|
110
183
|
const held = new Set(weekWindowsOver(dates).map((window) => window.from));
|
|
@@ -28,30 +28,70 @@ import { addDays, type CalendarWindow, calendarWindow } from "./window";
|
|
|
28
28
|
export const LEAD_IN = 10;
|
|
29
29
|
export const LEAD_OUT = 24;
|
|
30
30
|
|
|
31
|
-
/** What reaching an end adds to it. */
|
|
32
|
-
export const PAGE =
|
|
31
|
+
/** What reaching an end adds to it: the next week, and no more. */
|
|
32
|
+
export const PAGE = 7;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How far the run grows on the reader's scroll before it stops and asks.
|
|
36
|
+
*
|
|
37
|
+
* A year either way is further than anyone scrolls in one sitting, so the cap
|
|
38
|
+
* costs a reader nothing and bounds the damage of anything that asks for more
|
|
39
|
+
* days without one behind it. The strip is shorter than the reach it fetches at
|
|
40
|
+
* whenever the diary is sparse, and a run that grew on that alone walked out to
|
|
41
|
+
* a decade of empty weeks and took the address with it.
|
|
42
|
+
*/
|
|
43
|
+
export const CAP_WEEKS = 52;
|
|
44
|
+
const CAP_DAYS = CAP_WEEKS * 7;
|
|
33
45
|
|
|
34
46
|
export interface AgendaRange {
|
|
35
47
|
/** `YYYY-MM-DD`, inclusive. */
|
|
36
48
|
from: string;
|
|
37
49
|
/** `YYYY-MM-DD`, inclusive. */
|
|
38
50
|
to: string;
|
|
51
|
+
/** The earliest day scrolling alone may reach. */
|
|
52
|
+
floor: string;
|
|
53
|
+
/** The latest. */
|
|
54
|
+
ceiling: string;
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
export const rangeAround = (date: string): AgendaRange => ({
|
|
42
58
|
from: addDays(date, -LEAD_IN),
|
|
43
59
|
to: addDays(date, LEAD_OUT),
|
|
60
|
+
floor: addDays(date, -CAP_DAYS),
|
|
61
|
+
ceiling: addDays(date, CAP_DAYS),
|
|
44
62
|
});
|
|
45
63
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
from
|
|
49
|
-
});
|
|
64
|
+
/** The run has grown as far back as it may on its own. */
|
|
65
|
+
export const rangeAtFloor = (range: AgendaRange): boolean =>
|
|
66
|
+
range.from <= range.floor;
|
|
50
67
|
|
|
51
|
-
export const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
export const rangeAtCeiling = (range: AgendaRange): boolean =>
|
|
69
|
+
range.to >= range.ceiling;
|
|
70
|
+
|
|
71
|
+
const later = (a: string, b: string): string => (a > b ? a : b);
|
|
72
|
+
const earlier = (a: string, b: string): string => (a < b ? a : b);
|
|
73
|
+
|
|
74
|
+
/** At the cap this is the range it was given, identically, so nothing refetches. */
|
|
75
|
+
export const extendRangeStart = (range: AgendaRange): AgendaRange =>
|
|
76
|
+
rangeAtFloor(range)
|
|
77
|
+
? range
|
|
78
|
+
: { ...range, from: later(addDays(range.from, -PAGE), range.floor) };
|
|
79
|
+
|
|
80
|
+
export const extendRangeEnd = (range: AgendaRange): AgendaRange =>
|
|
81
|
+
rangeAtCeiling(range)
|
|
82
|
+
? range
|
|
83
|
+
: { ...range, to: earlier(addDays(range.to, PAGE), range.ceiling) };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The cap lifted, by a reader who said so. Another year opens behind the day
|
|
87
|
+
* the run has reached and the days already held stay where they are, so the
|
|
88
|
+
* strip keeps growing on the scroll from there.
|
|
89
|
+
*/
|
|
90
|
+
export const liftRangeFloor = (range: AgendaRange): AgendaRange =>
|
|
91
|
+
extendRangeStart({ ...range, floor: addDays(range.from, -CAP_DAYS) });
|
|
92
|
+
|
|
93
|
+
export const liftRangeCeiling = (range: AgendaRange): AgendaRange =>
|
|
94
|
+
extendRangeEnd({ ...range, ceiling: addDays(range.to, CAP_DAYS) });
|
|
55
95
|
|
|
56
96
|
/**
|
|
57
97
|
* The range that has a day in it. A day already held keeps the range it is in —
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export {
|
|
9
9
|
type AgendaRange,
|
|
10
10
|
busySpansByDate,
|
|
11
|
+
CAP_WEEKS,
|
|
11
12
|
datesInRange,
|
|
12
13
|
extendRangeEnd,
|
|
13
14
|
extendRangeStart,
|
|
@@ -15,8 +16,12 @@ export {
|
|
|
15
16
|
freeStretchesByDate,
|
|
16
17
|
LEAD_IN,
|
|
17
18
|
LEAD_OUT,
|
|
19
|
+
liftRangeCeiling,
|
|
20
|
+
liftRangeFloor,
|
|
18
21
|
PAGE,
|
|
19
22
|
rangeAround,
|
|
23
|
+
rangeAtCeiling,
|
|
24
|
+
rangeAtFloor,
|
|
20
25
|
rangeCovering,
|
|
21
26
|
weekKeyOf,
|
|
22
27
|
weekWindowsOver,
|