diff --git a/.changeset/prev-next-and-page-of.md b/.changeset/prev-next-and-page-of.md new file mode 100644 index 0000000..bd11821 --- /dev/null +++ b/.changeset/prev-next-and-page-of.md @@ -0,0 +1,94 @@ +--- +"@otta-sh/admin-presentation": patch +"@otta-sh/admin-react": patch +--- + +Previous, Next and `Page N of M` on the two React lists. + +The Orders and Pricing & inventory lists now carry a pager beside `Load more`. +`Next` moves forward one page and `Previous` moves back one, both exactly — the +console keeps the cursors it has already been handed and replays one to go back, +so there is no new query, no reverse keyset read, and nothing new on the wire. + +`Previous` re-requests the page rather than restoring the rows it had in hand. +The stack holds cursors, not pages: a request under a token the service already +issued answers with the collection as it stands now, agrees with a reload of the +same address, and does not grow without bound down a long scan. + +**The two ways forward are two acts, not two spellings of one.** +`Previous`/`Next` move a one-page window; `Load more` extends it and keeps the +rows above. Both advance the same position, so the page number counts either. +Paging on from an accumulated scan therefore continues from where the scan +reached and shows that page alone — which `Next` says in front of the click +rather than leaving to be discovered after it. While several pages are on +screen the position states the window it describes (`Pages 2–3 of 6`), because +"Page 3" over fifty rows beginning at page two tells whoever is reading the top +of that list the wrong number. + +**The page count is derived from what the list already holds.** The service +counts the filtered set alongside the page it returns and the plugin states the +page size it pages by, so the count is arithmetic over two values already on +screen — never a second request. It consumes the figure the count line actually +stated rather than the raw payload number, so a total the caption withheld +cannot reappear underneath it; the two lines can still drift if the store +changes between the count and the page, but only for that reason. A service that +reports no total leaves an em dash rather than a guess — absent is not one, and +it is not zero. A render standing on the last page states that page as the +count; where the arithmetic insists there are more pages than the one being +stood on, the two disagree outright and it states neither. + +**The stack resets whenever the filter does**, in the address bar as well as in +memory: a cursor is only meaningful against the predicate it was issued under, +so a stack that survived an apply would offer to step back into the set the +operator just left. + +**Returning to page one is page one**, whatever asked for it. A request that +carries no cursor comes back as the first page under the current predicate, so +it may state the whole set without a hedge, it gets the empty-collection words +rather than the page-scoped ones when it comes back empty, and on Pricing & +inventory it may both raise and clear the banner that says the low-stock +threshold could not be read. Only a page reached WITH a cursor carries that +banner forward, because such a request reports the filter as available by +contract and so has no answer of its own. + +**Paging survives the browser's own Back and Forward, and a drill-in.** The +address carries one cursor, which is what makes a link shareable; the history +entry carries the walk behind it, which a link must not. Without that, a Back +onto a page an operator had walked to came back as though it had been pasted in +— position unknown, `Previous` unavailable, two presses into a scan. The entry a +record's drill-in pushes carries it too, so opening an order from page two, +reloading, and pressing `Back to orders` returns to a list that still knows +where it is. Returning to page one deliberately pushes an entry, as any other +page does; only the recovery from a page that would not open still corrects the +entry in place. + +**A page nothing holds a record of cannot know its own number.** The position +reads `Page — of 6` and `Previous` is offered dimmed, carrying the reason: the +page before this one is not known here. It states that ignorance and not how the +operator arrived, which a reload, a bookmark and a link all reach identically. +Paging forward from such a page still comes back to it, and a link to the LAST +page keeps its pager rather than vanishing at the moment it is the only thing +that could say where the operator is. + +Where paging has stopped — a page that failed, or a continuation the service +refused mid-scan — the whole pager is withdrawn along with `Load more`, and the +rows stay exactly where they are. A failed page never clears the rows now, +whichever direction it was asked for; the refusal is drawn beside them, and its +wording names no direction, because three controls produce it and only one of +them is "more". + +On Pricing & inventory, a page reached with `Next` or `Previous` keeps the +banner raised when the low-stock threshold could not be read. Every request +carrying a cursor reports the filter as available by contract — the predicate +rode inside the token — so treating that as an answer would drop the banner at +the click of a control that has nothing to do with filtering, and start +captioning the whole catalogue as low stock. + +The controls are dimmed with `aria-disabled` rather than disabled outright, so +they keep their place in the tab order and their visible focus ring: pressing +`Next` onto the last page would otherwise take the control out from under the +operator's focus. The reason a control is dimmed is exposed as an accessible +description rather than only as a tooltip. The unavailable state is drawn as a +flat fill, a lighter border and a muted label rather than as a blanket opacity — +enough to read as "off" at a glance, and not so much that the word on it becomes +work to read. diff --git a/packages/admin-presentation/src/index.ts b/packages/admin-presentation/src/index.ts index 9ede54e..ad218b5 100644 --- a/packages/admin-presentation/src/index.ts +++ b/packages/admin-presentation/src/index.ts @@ -114,11 +114,20 @@ export { APPLY_FILTERS_LABEL, CLEAR_FILTERS_LABEL, LOAD_MORE_LABEL, + NEXT_AT_END_TITLE, + NEXT_PAGE_LABEL, + NEXT_RELEASES_SCAN_TITLE, NOTHING_ON_PAGE, + PAGER_LABEL, PAGE_SCOPED_SUFFIX, PAGE_ZERO, + PREVIOUS_AT_START_TITLE, + PREVIOUS_PAGE_LABEL, + PREVIOUS_UNWALKED_TITLE, SCAN_FURTHER, listOutcome, + pageCount, + pagePositionLine, rowCountLine, type ListOutcome, type ListOutcomeOptions, @@ -152,7 +161,7 @@ export { ORDERS_BACK_LABEL, ORDERS_EMPTY, ORDERS_LIST_INTRO, - ORDERS_LOAD_MORE_FAILED_TITLE, + ORDERS_PAGE_FAILED_TITLE, ORDERS_NO_MATCH, ORDERS_NOUN, ORDERS_SEARCH_LABEL, @@ -213,7 +222,7 @@ export { PRODUCTS_BACK_LABEL, PRODUCTS_EMPTY, PRODUCTS_LIST_INTRO, - PRODUCTS_LOAD_MORE_FAILED_TITLE, + PRODUCTS_PAGE_FAILED_TITLE, PRODUCTS_LOW_STOCK_NOUN, PRODUCTS_LOW_STOCK_NO_MATCH, PRODUCTS_NOUN, diff --git a/packages/admin-presentation/src/list-outcome.ts b/packages/admin-presentation/src/list-outcome.ts index beea204..aced7b9 100644 --- a/packages/admin-presentation/src/list-outcome.ts +++ b/packages/admin-presentation/src/list-outcome.ts @@ -20,6 +20,7 @@ * IO-FREE — pure `Intl` and string work, safe inside the workerd sandbox (G7) * and in a browser. */ +import { ABSENT } from "./copy.js"; import { DATE_LOCALE } from "./datetime.js"; /** How a screen names one row and many. Six screens describe their rows @@ -79,6 +80,28 @@ export const CLEAR_FILTERS_LABEL = "Clear filters"; export const APPLY_FILTERS_LABEL = "Apply filters"; export const LOAD_MORE_LABEL = "Load more"; +/** + * The service's `total`, or `undefined` when this render may not state it. + * + * VALIDATED RATHER THAN TRUSTED, and shared by everything that would state it, + * which is the whole reason it is a function rather than a line inside the count + * line. A non-integer, a negative, or a count BELOW the rows already on screen is + * a service disagreeing with itself; the safe direction is the claim a render can + * back up on its own. `total < count` is impossible for a count and a page taken + * under one predicate, but they are two statements, so a concurrent insert or + * delete between them is the ordinary case — only the direction that would + * UNDERSTATE what the operator can see is refused. + * + * ZERO ROWS REFUSE EVERY TOTAL. A `total` above an empty page is the same + * self-contradiction from the other side, and nothing may render it: not the + * count line, not the page count. + */ +function statedTotal(count: number, total: number | undefined): number | undefined { + if (count <= 0) return undefined; + if (total === undefined || !Number.isSafeInteger(total) || total < count) return undefined; + return total; +} + /** * `17 orders` · `1 order` · `25 orders on this page`, or `undefined` at zero. * @@ -104,13 +127,9 @@ export const LOAD_MORE_LABEL = "Load more"; * counted every product, so passing that number would caption the rows on * screen as though the filter had run). * - * `total` IS VALIDATED HERE RATHER THAN TRUSTED: a non-integer, negative, or - * below-the-page count is a service disagreeing with itself, and the - * page-scoped fallback — a claim this render can back up on its own — is the - * safe direction. `total < count` is impossible for a count and a page taken - * under one predicate, but they are two statements, so a concurrent - * insert/delete between them is the ordinary case; only the direction that - * would UNDERSTATE the rows an operator can see is refused. + * `total` IS VALIDATED RATHER THAN TRUSTED, through {@link statedTotal} — which + * is shared with the page count beneath this line precisely so that a figure one + * of them refuses cannot reappear in the other. * * NOTHING HERE INVENTS A TOTAL. A count that says the set is bigger than the * page must have been told so by the service; a renderer that guessed one would @@ -142,20 +161,169 @@ export function rowCountLine( // "17 orders" sitting immediately above "No orders yet" or "Nothing on this // page", is the screen contradicting itself in two adjacent blocks. if (count <= 0) return undefined; - const usable = - opts.total !== undefined && - Number.isSafeInteger(opts.total) && - opts.total >= 0 && - opts.total >= count; - const n = usable ? (opts.total ?? 0) : count; - if (n <= 0) return undefined; + const stated = statedTotal(count, opts.total); + const n = stated ?? count; const word = COUNT_PLURALS.select(n) === "one" ? noun.one : noun.other; const formatted = COUNT_NUMERALS.format(n); - return usable || opts.complete + return stated !== undefined || opts.complete ? `${formatted} ${word}` : `${formatted} ${word} ${opts.scopeSuffix ?? PAGE_SCOPED_SUFFIX}`; } +/** The pager's two controls, authored here for the same reason `Load more` is: + * two React lists render them, and a label spelled per screen is the drift this + * package exists to prevent. */ +export const PREVIOUS_PAGE_LABEL = "Previous"; +export const NEXT_PAGE_LABEL = "Next"; + +/** The pager's own accessible name — it is a second navigation region on a + * screen that already has the admin's, so it has to say which one it is. */ +export const PAGER_LABEL = "Pages"; + +/** An EN DASH joins the two ends of a page range — deliberately NOT the em dash + * {@link ABSENT} reserves for a missing value, because the position line is the + * one place that can carry both at once and `Pages 2—3 of —` would spell a + * range and an absence with the same glyph. */ +const PAGE_RANGE_DASH = "\u2013"; + +/** + * WHY A PAGER CONTROL IS DIMMED, in the three cases it can be, and what `Next` + * warns before it is pressed. + * + * A control the operator cannot use and cannot see a reason for is the same + * defect as a zero state with no words. Two of the three are self-evident once + * said ("first page", "last page"); the third is not evident at all and is the + * one this console had to decide. + * + * IT STATES IGNORANCE, NEVER PROVENANCE. The earlier wording said the page "was + * opened from a link", and that is a claim about how the operator got here which + * this tier cannot make: the same state is produced by a reload, a bookmark, a + * traversal that outlived its entry's stored stack, and a host that re-mounted + * the screen. All the screen knows is that it holds no record of the page before + * this one, so that — and only that — is what it says. Same doctrine as the + * refusal notices: name the fact, never the cause. + * + * AND `Next` NAMES ITS COST while it still has one. Pressing it with several + * pages accumulated shows the next page ALONE, so the scan the operator built is + * released; a control that quietly discards gathered work is the defect, and one + * sentence in front of the click is the cheapest possible fix. + */ +export const PREVIOUS_AT_START_TITLE = "This is the first page."; +export const NEXT_AT_END_TITLE = "There is no page after this one."; +export const PREVIOUS_UNWALKED_TITLE = "The page before this one is not known here."; +export const NEXT_RELEASES_SCAN_TITLE = + "Shows the next page on its own — the pages loaded above are released."; + +/** + * HOW MANY PAGES THERE ARE — derived, never fetched. + * + * THE WHOLE POINT: both halves are already on the wire. The service counts the + * filtered set alongside the page it returns (`total`), and the plugin sends the + * keyset limit it paged by (`vocabulary.pageLimit`), so the page count is + * arithmetic over two values the render is already holding. A second query for + * it would be a request bought with nothing. + * + * IT IS FED THE FIGURE THE COUNT LINE ACTUALLY STATED — `listOutcome`'s + * `statedTotal`, not a raw payload number — and validates it again through the + * same {@link statedTotal} gate. That is what keeps the two lines SOURCED from + * one decision: a total the caption withheld cannot reappear as a page count + * underneath it. It is not a proof that no two numbers on this screen can ever + * read oddly together — the count and the page are still two statements taken at + * two moments, and a write between them moves the boundary — but the DERIVATION + * is single, so a disagreement can only come from the store changing, never from + * the two lines having made up their minds separately. An absent, contradictory + * or below-the-rows total yields `undefined`, which renders as an em dash, never + * as `1` and never as `0`. + * + * WHERE THE REST OF THE PAGER LIVES: the client-side stack and the view it + * derives are `PageTrail` and `pagerView` in `@otta-sh/admin-react`'s + * `accumulate.ts`; the markup is `PagerButton` in its `ui.tsx`. This module owns + * the words and the arithmetic and nothing else. + * + * A PAGE SIZE MUST BE A WHOLE POSITIVE NUMBER. Anything else describes no + * paging at all, and dividing by it would invent a figure out of a malformed + * one. + * + * IT IS AN APPROXIMATION UNDER CONCURRENCY, exactly as the count line is: the + * count and the page were taken at two moments, and rows inserted between them + * move the boundary. That is the same accuracy the operator already reads on + * the line above; it is not a new claim. + */ +export function pageCount( + rows: number, + opts: { total?: number; pageSize?: number }, +): number | undefined { + const total = statedTotal(rows, opts.total); + if (total === undefined) return undefined; + const size = opts.pageSize; + if (size === undefined || !Number.isSafeInteger(size) || size <= 0) return undefined; + return Math.ceil(total / size); +} + +/** + * `Page 2 of 6` · `Pages 2–3 of 6` — and what it says when a half is unknown. + * + * IT DESCRIBES A WINDOW, NOT A POINT, and `span` is why. A list that + * ACCUMULATES has more than one page on screen at once, and captioning fifty + * rows drawn from two requests "Page 2 of 6" states only where the window ENDS — + * an operator reading the top of that list is looking at page 1 under a line + * that says 2. So a span above one is rendered as the range it is. The start is + * derived rather than tracked: every page added to the window advances the + * position by exactly one, so `index − span + 1` is the page the window opens + * on, whatever mixture of steps built it. + * + * BOTH HALVES CAN BE ABSENT, INDEPENDENTLY, and each renders {@link ABSENT}: + * + * - **M unknown** — the service sent no `total`, or sent one this render + * refuses. `Page 3 of —`. NEVER "of 1": that would state the operator is + * looking at the whole set at the exact moment nothing knows how big it is, + * and never "of 0", which is the absent-rendered-as-zero failure this + * console forbids everywhere else. + * - **N unknown** — nothing here holds a record of the pages before this one, + * so there is no walk to count. `Page — of 6`. The list still knows the size + * of the collection; it does not know where in it the operator is standing, + * and the dash is that fact rather than a hidden one. The noun still follows + * the span — `Pages — of 6` for a window of several — because how many pages + * are on screen is known even when their numbers are not. + * + * NEITHER KNOWN RENDERS NOTHING. "Page — of —" is a line that occupies space to + * say it has nothing to say. + * + * AND M IS REFUSED WHEN IT FALLS BELOW N, the same rule {@link statedTotal} + * applies one level down: the two figures come from statements taken at + * different moments, so a concurrent delete can shrink the derived count below + * the page the operator actually walked to. `Page 7 of 6` is a contradiction on + * screen; a dash is an absence. + */ +export function pagePositionLine(opts: { + index?: number; + pages?: number; + /** How many pages are on screen at once. Absent or 1 is the ordinary single + * page; above that the line states the range. */ + span?: number; +}): string | undefined { + const { index, pages } = opts; + if (index === undefined && pages === undefined) return undefined; + const span = + opts.span !== undefined && Number.isSafeInteger(opts.span) && opts.span > 1 ? opts.span : 1; + const usablePages = + pages !== undefined && Number.isSafeInteger(pages) && pages > 0 && (index ?? 0) <= pages + ? pages + : undefined; + const m = usablePages === undefined ? ABSENT : COUNT_NUMERALS.format(usablePages); + if (span === 1) { + return `Page ${index === undefined ? ABSENT : COUNT_NUMERALS.format(index)} of ${m}`; + } + if (index === undefined) return `Pages ${ABSENT} of ${m}`; + // CLAMPED AT ONE because `span` counts RESPONSES, not pages of the collection: + // a window built from a deep link plus a `Load more` spans two responses while + // its first page number is unknown, and a caller that ever hands a span larger + // than the position it belongs to would otherwise compute a page zero — or a + // negative one — and print it. + const start = COUNT_NUMERALS.format(Math.max(1, index - span + 1)); + return `Pages ${start}${PAGE_RANGE_DASH}${COUNT_NUMERALS.format(index)} of ${m}`; +} + /** The wording of ONE zero state. Screens author every string. */ export interface ZeroStateCopy { readonly title: string; @@ -174,8 +342,11 @@ export interface ZeroStateCopy { * delete leave a cursor pointing past the last row. * * Deliberately offers NOTHING to click: no filter is on, so there is nothing to - * clear, and neither surface has a "back to the first page" control to - * fabricate. + * clear, and this state must not invent a control of its own. That is unchanged + * by the React pager below it — `Previous` is a control the SCREEN already + * offers, standing where it always stands and reachable from any page; it is not + * an affordance this zero state fabricated, and the Block Kit surface, which has + * no pager, still renders exactly these words with nothing beside them. */ export const PAGE_ZERO: ZeroStateCopy = { title: "Nothing on this page", @@ -205,17 +376,42 @@ export type ZeroStateOffer = "clear-filters" | "way-in" | "none"; * 4. **Zero, filtered, last page** (`kind: "empty"`, offer `clear-filters`) — * the screen's `noMatch` copy plus the undo, replacing the table. */ +/** + * THE FIGURE THE COUNT LINE ACTUALLY USED, carried out so nothing downstream has + * to re-derive it. + * + * IT IS ON EVERY VARIANT, `undefined` INCLUDED, and that is the point. The page + * count beneath the list is the second thing on screen that would state a + * `total`, and re-validating the caller's raw one there would be a SECOND + * decision about the same number — which is exactly how "25 orders on this page" + * ends up sitting above "Page 2 of 6". A caller feeds this value to + * {@link pageCount} instead of the raw one, so a total this ladder withheld — + * because the page was narrowed after the fetch, because the service contradicted + * itself, because the page is empty — cannot reappear one line down. + */ +interface StatedTotal { + readonly statedTotal: number | undefined; +} + export type ListOutcome = - | { readonly kind: "rows"; readonly countLine: string | undefined; readonly emptyText: string } - | { readonly kind: "scan"; readonly countLine: undefined; readonly scanNote: string } - | { + | ({ + readonly kind: "rows"; + readonly countLine: string | undefined; + readonly emptyText: string; + } & StatedTotal) + | ({ + readonly kind: "scan"; + readonly countLine: undefined; + readonly scanNote: string; + } & StatedTotal) + | ({ readonly kind: "empty"; readonly countLine: undefined; readonly title: string; readonly description: string; readonly offer: ZeroStateOffer; readonly emptyText: string; - }; + } & StatedTotal); export interface ListOutcomeOptions { /** Rows on the page about to be rendered. */ @@ -284,6 +480,10 @@ export interface ListOutcomeOptions { export function listOutcome(opts: ListOutcomeOptions): ListOutcome { const narrowedAfterFetch = opts.countScope === "narrowed-after-fetch"; + // THE ONE PLACE THE TOTAL IS DECIDED. Everything below reads this, including + // what is handed back to the caller for the page count — see {@link + // StatedTotal}. + const stated = narrowedAfterFetch ? undefined : statedTotal(opts.count, opts.total); const countLine = rowCountLine(opts.count, opts.noun, { complete: opts.firstPage && !opts.hasNext && !narrowedAfterFetch, // REFUSED, NOT MERELY UNCLAIMED: a `total` is dropped here whenever the @@ -293,11 +493,11 @@ export function listOutcome(opts: ListOutcomeOptions): ListOutcome { // present (a `filterUnavailable` page that forwards the service's own // count): the mislabel would still be a bug, but it can no longer // resurrect the whole-set phrasing this scope exists to withhold. - ...(!narrowedAfterFetch && opts.total !== undefined ? { total: opts.total } : {}), + ...(stated !== undefined ? { total: stated } : {}), ...(opts.scopeSuffix !== undefined ? { scopeSuffix: opts.scopeSuffix } : {}), }); if (opts.count > 0) { - return { kind: "rows", countLine, emptyText: opts.noMatch.emptyText }; + return { kind: "rows", countLine, emptyText: opts.noMatch.emptyText, statedTotal: stated }; } if (opts.hasNext) { const lead = opts.filtered ? opts.noMatch.emptyText : NOTHING_ON_PAGE; @@ -305,6 +505,7 @@ export function listOutcome(opts: ListOutcomeOptions): ListOutcome { kind: "scan", countLine: undefined, scanNote: opts.noMatch.scanNote ?? `${lead} ${SCAN_FURTHER}`, + statedTotal: stated, }; } // THE SCREEN'S `empty` COPY IS A WHOLE-COLLECTION CLAIM, so it is gated on @@ -327,5 +528,6 @@ export function listOutcome(opts: ListOutcomeOptions): ListOutcome { description: copy.description, offer, emptyText: opts.noMatch.emptyText, + statedTotal: stated, }; } diff --git a/packages/admin-presentation/src/orders-copy.ts b/packages/admin-presentation/src/orders-copy.ts index 4a08d23..13eb912 100644 --- a/packages/admin-presentation/src/orders-copy.ts +++ b/packages/admin-presentation/src/orders-copy.ts @@ -105,13 +105,18 @@ export const ORDERS_STALE_CLEARED_NOTE = "The orders that were here have been cleared — they were from an earlier request and may no longer be current."; /** - * A CONTINUATION failure's title, which is a smaller claim than the server's. + * A PAGING failure's title, which is a smaller claim than the server's. * * The service answers a whole-collection refusal ("Orders could not be * reached"), and on page two that is disproved by the rows already on screen. - * What failed is the next page, so that is what the title says. + * What failed is one page, so that is what the title says. + * + * IT NAMES NO DIRECTION, and that is a correction rather than a preference: + * three controls now produce this card — `Load more`, `Next` and `Previous` — + * and "load more" over a failed `Previous` describes a request the operator did + * not make. The claim that has to be small is "one page", not "the page after". */ -export const ORDERS_LOAD_MORE_FAILED_TITLE = "Couldn't load more orders"; +export const ORDERS_PAGE_FAILED_TITLE = "Couldn't open that page of orders"; /** * The reconciliation alert's sentence, on the order detail. diff --git a/packages/admin-presentation/src/products-copy.ts b/packages/admin-presentation/src/products-copy.ts index 43292cb..06bcdbc 100644 --- a/packages/admin-presentation/src/products-copy.ts +++ b/packages/admin-presentation/src/products-copy.ts @@ -73,15 +73,17 @@ export const PRODUCTS_LOW_STOCK_NOUN: RowNoun = { }; /** - * A CONTINUATION failure's title, which is a smaller claim than the server's. + * A PAGING failure's title, which is a smaller claim than the server's. * * The same ruling the Orders list already made, and this screen inherits it * along with the accumulated-pages state itself: the service answers a * whole-collection refusal ("Products could not be reached"), and rendering that * above rows that are still on screen states something those rows disprove. What - * failed is the next page, so that is what the title says. + * failed is one page, so that is what the title says — and it names no + * DIRECTION, because `Load more`, `Next` and `Previous` all land here and only + * one of them is "more". */ -export const PRODUCTS_LOAD_MORE_FAILED_TITLE = "Couldn't load more products"; +export const PRODUCTS_PAGE_FAILED_TITLE = "Couldn't open that page of products"; /** * The standing half of the list's intro line — the row count goes in front of diff --git a/packages/admin-presentation/test/presentation.test.ts b/packages/admin-presentation/test/presentation.test.ts index 5715f5b..b176235 100644 --- a/packages/admin-presentation/test/presentation.test.ts +++ b/packages/admin-presentation/test/presentation.test.ts @@ -32,7 +32,7 @@ import { NO_CHANGES_TO_SAVE, NO_TAX_CLASS, ORDERS_EMPTY, - ORDERS_LOAD_MORE_FAILED_TITLE, + ORDERS_PAGE_FAILED_TITLE, ORDERS_NOUN, ORDERS_NO_MATCH, ORDERS_STALE_CLEARED_NOTE, @@ -73,10 +73,18 @@ import { listOutcome, tabUnsavedLabel, majorUnits, + NEXT_AT_END_TITLE, + NEXT_PAGE_LABEL, + NEXT_RELEASES_SCAN_TITLE, onHandCell, orderStateCell, + pageCount, + pagePositionLine, parseOnHandWatermark, parseStockQty, + PREVIOUS_AT_START_TITLE, + PREVIOUS_PAGE_LABEL, + PREVIOUS_UNWALKED_TITLE, priceChangeSummary, priceGroupLabel, pricePendingLine, @@ -120,11 +128,15 @@ describe("the words a failed load is answered with (F1, F2)", () => { ); }); - test("a continuation failure makes the SMALLER claim", () => { + test("a paging failure makes the SMALLER claim, and names no direction", () => { // The service's refusal is about the whole collection; on page two the rows - // already on screen disprove that. What failed is the next page. - expect(ORDERS_LOAD_MORE_FAILED_TITLE).toBe("Couldn't load more orders"); - expect(ORDERS_LOAD_MORE_FAILED_TITLE).toContain("more"); + // already on screen disprove that. What failed is one page. + expect(ORDERS_PAGE_FAILED_TITLE).toBe("Couldn't open that page of orders"); + expect(ORDERS_PAGE_FAILED_TITLE).toContain("that page"); + // THREE CONTROLS LAND HERE — `Load more`, `Next` and `Previous` — so a + // title that said "more" would describe a request a merchant pressing + // `Previous` never made. + expect(ORDERS_PAGE_FAILED_TITLE).not.toMatch(/\bmore\b|\bnext\b|\bafter\b/i); }); }); @@ -593,6 +605,201 @@ describe("INC-23's exact count, shared by both surfaces", () => { }); }); +describe("Page N of M — derived from what the list already has, never fetched", () => { + const noun = ORDERS_NOUN; + + test("the labels are authored here, not at the two call sites", () => { + // Two React lists render this pager and must not drift on the words, for + // the same reason `Load more` and `Apply filters` live beside them. + expect(PREVIOUS_PAGE_LABEL).toBe("Previous"); + expect(NEXT_PAGE_LABEL).toBe("Next"); + }); + + test("M is the exact total over the page size — the two the wire already carries", () => { + // COUNT(*) under the page's own predicate, and the limit the plugin sends + // with every request. Neither is a new query; the page count is arithmetic + // over two values already on screen. + expect(pageCount(25, { total: 137, pageSize: 25 })).toBe(6); + expect(pageCount(25, { total: 125, pageSize: 25 })).toBe(5); + // A collection smaller than one page is one page. + expect(pageCount(1, { total: 1, pageSize: 25 })).toBe(1); + // An ACCUMULATED scan counts the same way: `rows` is what is on screen, and + // the page size is still the page size. + expect(pageCount(50, { total: 137, pageSize: 25 })).toBe(6); + }); + + test("an ABSENT total is not a page count — never 1, never 0", () => { + // The hazard this test exists for. `total` is optional by design (a service + // that omits it, and a products page whose low-stock predicate never ran), + // and "of 1" would state that the operator is looking at the whole set at + // the exact moment nothing knows how big it is. + expect(pageCount(25, { pageSize: 25 })).toBeUndefined(); + expect(pageCount(25, { total: undefined, pageSize: 25 })).toBeUndefined(); + // And an absent page size is the same refusal from the other side. + expect(pageCount(25, { total: 137 })).toBeUndefined(); + }); + + test("M IS REFUSED ON EXACTLY THE TOTALS THE COUNT LINE REFUSES", () => { + // THE COORDINATION, PINNED. "Page 2 of 6" and "137 orders" are two + // sentences about one set, so a total the count line will not state must + // not reappear as a page count — otherwise a render that hedges to + // "25 orders on this page" would sit under a confident "of 6" derived from + // the very figure it just refused. + for (const bad of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(rowCountLine(25, noun, { complete: false, total: bad })).toBe( + "25 orders on this page", + ); + expect(pageCount(25, { total: bad, pageSize: 25 })).toBeUndefined(); + } + // Below the rows on screen is the one direction both refuse: it would + // understate what the operator can already see. + expect(rowCountLine(50, noun, { complete: false, total: 3 })).toBe("50 orders on this page"); + expect(pageCount(50, { total: 3, pageSize: 25 })).toBeUndefined(); + // A page size that is not a positive whole number describes no paging. + for (const bad of [0, -5, 2.5, Number.NaN]) { + expect(pageCount(25, { total: 137, pageSize: bad })).toBeUndefined(); + } + }); + + test("zero rows have no page count, exactly as they have no count line", () => { + // A total above an empty page is a service disagreeing with itself, and + // the count line already suppresses it. The pager says nothing rather than + // captioning an empty page "Page 1 of 6". + expect(rowCountLine(0, noun, { complete: false, total: 137 })).toBeUndefined(); + expect(pageCount(0, { total: 137, pageSize: 25 })).toBeUndefined(); + }); + + test("the line reads `Page 2 of 6`, through Intl, on both halves", () => { + expect(pagePositionLine({ index: 2, pages: 6 })).toBe("Page 2 of 6"); + // The thousands separator is the visible proof that `Intl.NumberFormat` is + // in the path on BOTH numbers — `String(n)` cannot produce it. + expect(pagePositionLine({ index: 1234, pages: 5678 })).toBe("Page 1,234 of 5,678"); + }); + + test("an unknown M is an EM DASH — the house rule for absent, never a guess", () => { + expect(pagePositionLine({ index: 3 })).toBe(`Page 3 of ${ABSENT}`); + expect(pagePositionLine({ index: 3, pages: undefined })).toBe(`Page 3 of ${ABSENT}`); + // The failure this forbids in as many words: absent is not one, and it is + // not zero. + expect(pagePositionLine({ index: 3 })).not.toContain("of 1"); + expect(pagePositionLine({ index: 3 })).not.toContain("of 0"); + }); + + test("an unknown N is an em dash too — a deep link cannot know its own page", () => { + // A page opened straight from an address has no walk behind it, so the + // list knows the SIZE of the collection and not where in it the operator + // is standing. That is a value this console renders as a dash, like every + // other absent one. + expect(pagePositionLine({ pages: 6 })).toBe(`Page ${ABSENT} of 6`); + }); + + test("knowing NEITHER renders nothing at all", () => { + // "Page — of —" states nothing and occupies a line saying so. + expect(pagePositionLine({})).toBeUndefined(); + expect(pagePositionLine({ index: undefined, pages: undefined })).toBeUndefined(); + }); + + test("a page count BELOW the page you are on is refused, not printed", () => { + // Same doctrine as `rowCountLine` refusing a total below its own rows: the + // two figures come from statements taken at different moments, and a + // concurrent delete can shrink the derived count under the page the + // operator walked to. "Page 7 of 6" is the contradiction; a dash is not. + expect(pagePositionLine({ index: 7, pages: 6 })).toBe(`Page 7 of ${ABSENT}`); + // Equal is the last page, and perfectly ordinary. + expect(pagePositionLine({ index: 6, pages: 6 })).toBe("Page 6 of 6"); + }); + + test("the unavailable controls explain themselves, and name no cause they lack", () => { + // A dimmed control with no explanation is the same defect as a silent empty + // state: the operator is told what they cannot do and not why. + expect(PREVIOUS_AT_START_TITLE).toBe("This is the first page."); + expect(NEXT_AT_END_TITLE).toBe("There is no page after this one."); + // THE UNKNOWN CASE STATES IGNORANCE, NOT PROVENANCE. An earlier wording said + // the page "was opened from a link" — a claim about how the operator got + // here that this tier cannot make: a reload, a bookmark, a traversal whose + // entry lost its stack and a host remount all produce the identical state. + // All the screen knows is that it holds no record of the page before. + expect(PREVIOUS_UNWALKED_TITLE).toBe("The page before this one is not known here."); + expect(PREVIOUS_UNWALKED_TITLE).not.toMatch(/link|bookmark|reload|shared|pasted/i); + expect(PREVIOUS_UNWALKED_TITLE).not.toMatch(/expired|invalid|error|failed/i); + }); + + test("`Next` states its COST before the click, while it still has one", () => { + // Paging on from an accumulated scan shows the next page alone, so the + // pages the operator gathered are released. A control that quietly discards + // gathered work is the defect; one sentence in front of the click is the + // fix. + expect(NEXT_RELEASES_SCAN_TITLE).toContain("released"); + expect(NEXT_RELEASES_SCAN_TITLE).toContain("above"); + }); + + test("an ACCUMULATED window states the range, not only where it ends", () => { + // "Page 3 of 6" over fifty rows beginning at page two tells an operator + // reading the top of that list the wrong page number. + expect(pagePositionLine({ index: 3, pages: 6, span: 2 })).toBe("Pages 2\u20133 of 6"); + expect(pagePositionLine({ index: 6, pages: 6, span: 6 })).toBe("Pages 1\u20136 of 6"); + // A span of one is the ordinary single page and says so in the singular. + expect(pagePositionLine({ index: 3, pages: 6, span: 1 })).toBe("Page 3 of 6"); + // AN EN DASH JOINS A RANGE, never the em dash reserved for an absence — the + // one line that can carry both must not spell them the same way. + expect(pagePositionLine({ index: 3, pages: 6, span: 2 })).not.toContain(ABSENT); + // The numerals can be unknown while the SPAN is not: how many pages are on + // screen is known even when their numbers are not. + expect(pagePositionLine({ pages: 6, span: 2 })).toBe(`Pages ${ABSENT} of 6`); + }); + + test("the effective total the COUNT LINE used is what the page count must consume", () => { + // THE COUPLING, PINNED. A page narrowed after the fetch withholds its total + // from the caption; handing the raw payload figure to `pageCount` would put + // "of 6" under a line that had just refused the 137 it came from. The ladder + // hands back the figure it actually used, and the caller feeds THAT. + const narrowed = listOutcome({ + count: 4, + filtered: true, + firstPage: true, + hasNext: true, + total: 137, + countScope: "narrowed-after-fetch", + noun: ORDERS_NOUN, + empty: ORDERS_EMPTY, + noMatch: ORDERS_NO_MATCH, + }); + expect(narrowed.countLine).toBe("4 orders on this page"); + expect(narrowed.statedTotal).toBeUndefined(); + expect(pageCount(4, { total: narrowed.statedTotal, pageSize: 25 })).toBeUndefined(); + + // And the ordinary scope hands back exactly what it stated. + const served = listOutcome({ + count: 25, + filtered: false, + firstPage: true, + hasNext: true, + total: 137, + countScope: "service-filtered", + noun: ORDERS_NOUN, + empty: ORDERS_EMPTY, + noMatch: ORDERS_NO_MATCH, + }); + expect(served.countLine).toBe("137 orders"); + expect(served.statedTotal).toBe(137); + expect(pageCount(25, { total: served.statedTotal, pageSize: 25 })).toBe(6); + + // A zero-row page states no count and therefore offers no page count. + const emptyPage = listOutcome({ + count: 0, + filtered: false, + firstPage: false, + hasNext: false, + total: 137, + countScope: "service-filtered", + noun: ORDERS_NOUN, + empty: ORDERS_EMPTY, + noMatch: ORDERS_NO_MATCH, + }); + expect(emptyPage.statedTotal).toBeUndefined(); + }); +}); + // ── INC-21: the Pricing & inventory vocabulary ─────────────────────────────── describe("the On hand cell keeps three cases apart that must never be folded", () => { diff --git a/packages/admin-react/src/accumulate.ts b/packages/admin-react/src/accumulate.ts index e6383d2..c5c4a5a 100644 --- a/packages/admin-react/src/accumulate.ts +++ b/packages/admin-react/src/accumulate.ts @@ -1,3 +1,14 @@ +import { + NEXT_AT_END_TITLE, + NEXT_PAGE_LABEL, + NEXT_RELEASES_SCAN_TITLE, + PREVIOUS_AT_START_TITLE, + PREVIOUS_PAGE_LABEL, + PREVIOUS_UNWALKED_TITLE, + pageCount, + pagePositionLine, +} from "@otta-sh/admin-presentation"; + /** * What `Load more` does to the rows already on screen (F24). * @@ -80,7 +91,43 @@ export function mergeById( */ export interface PendingCursor { readonly filter: F; - readonly value: string; + /** + * WHICH PAGE WAS ASKED FOR — `undefined` is page one, which is a page like any + * other and is asked for by sending no token at all. + * + * WHY THIS IS OPTIONAL RATHER THAN A `null` CURSOR. The state that holds this + * value distinguishes two things a bare `string | null` cannot: "no page has + * been asked for, this is a fresh load" (the value is `null`) and "the pager + * was pressed and it asked for page one" (the value is an object whose + * `value` is `undefined`). The difference decides what a FAILURE costs — a + * fresh load that fails disproves the rows on screen, and a page move that + * fails disproves nothing — and it was the one distinction the earlier + * `continuation = cursor !== undefined` test could not make, which is how a + * failed `Previous` onto page one destroyed a screenful of rows. + */ + readonly value: string | undefined; + /** + * Does this cursor CONTINUE the rows on screen, or REPLACE them? + * + * TWO CONTROLS NOW ADVANCE THE SAME CURSOR AND ONLY ONE ACCUMULATES. + * `Load more` extends the window — the rows above it are the operator's scan + * and merging is the whole point (see {@link mergeById}). `Next` and + * `Previous` MOVE the window: what comes back is one page, standing on its + * own, and merging it would paste page three onto page two and caption the + * result as a scan the operator never made. + * + * IT RIDES ON THE CURSOR RATHER THAN BESIDE IT, for the reason the filter + * does: two pieces of state set by one click can still be read by an effect in + * either order, and the pair that must never exist is a REPLACE cursor read as + * an EXTEND one. Carrying the intent in the same value makes that + * unrepresentable rather than merely unlikely. + * + * ABSENT IS REPLACE, and that is the safe default rather than an accident: a + * cursor decoded from an address ({@link seedCursor}) names a page and has + * nothing on screen to continue, and a pager step is the same. Exactly one + * call site per screen sets this, and it is the one labelled `Load more`. + */ + readonly extend?: boolean; } /** @@ -109,9 +156,53 @@ export function continuationCursor( cursor: PendingCursor | null, applied: F, ): string | undefined { - return cursor !== null && cursor.filter === applied ? cursor.value : undefined; + return askedForPage(cursor, applied) ? cursor?.value : undefined; +} + +/** + * DID THE OPERATOR ASK FOR THIS PAGE, or is this a fresh load? + * + * THE TWO ARE NOT THE SAME REQUEST EVEN WHEN THEY PUT THE SAME BYTES ON THE + * WIRE. `Previous` onto page one and a filter apply both send no cursor, and + * {@link continuationCursor} therefore answers `undefined` for both — but one is + * a MOVE between pages of a query whose rows are on screen, and the other is a + * fresh query whose rows have not been fetched yet. What separates them is + * whether a cursor OBJECT exists at all. + * + * IT DECIDES WHAT A FAILURE COSTS. A fresh load that fails disproves the rows on + * screen — they answered a different question — so they are cleared. A page move + * that fails disproves nothing: the rows still answer the query that produced + * them, so they stand and the refusal is drawn beside them. Reading + * "continuation" off the wire instead was how a failed `Previous` onto page one + * wiped a screenful of rows that were still true. + * + * SAME IDENTITY TEST as {@link continuationCursor}, and for the same reason: a + * cursor issued under a filter that has since been replaced is not a move within + * anything, so the request it belongs to is a fresh load of the new filter. + */ +export function askedForPage(cursor: PendingCursor | null, applied: F): boolean { + return cursor !== null && cursor.filter === applied; } +/** + * WHAT A RESPONSE DOES TO THE ROWS ALREADY ON SCREEN — the three answers, named. + * + * - `reset` — a FRESH LOAD. A first mount, a filter apply, or a page the + * service refused and answered with page one instead. Whatever was on screen + * answered a different question, so it goes, and the render starts at the + * first page. + * - `extend` — `Load more`. The rows above are the operator's scan; the + * incoming page merges into them by identity ({@link mergeById}) and the + * window grows by one page. + * - `replace` — a PAGER STEP, or a deep link. The window MOVES: the incoming + * page stands on its own, and it is not the first page. + * + * Both lists spell this the same way and derive it the same way, which is the + * point of naming it rather than passing two booleans that each list combines in + * its own words. + */ +export type PageArrival = "reset" | "extend" | "replace"; + /** * THE CURSOR A DEEP LINK ARRIVED WITH, bound to the filter that link decoded to. * @@ -237,29 +328,371 @@ export const CURSOR_RESET_DESCRIPTION = "Showing the first page of these filters instead. Whether that page is gone or the request simply failed, the answer that came back does not say."; /** - * WHAT A SCAN THAT CANNOT BE CONTINUED SAYS — and why it is not the sentence - * above. + * WHAT A LIST THAT CANNOT BE PAGED SAYS — and why it is not the sentence above. * * THE TWO REFUSALS DIFFER IN WHAT IS AT STAKE, not in what went wrong. A deep - * link naming a page that will not open costs nothing: there is no scan yet, and - * page one of its filters is a complete answer. A `Load more` refused halfway - * through costs the pages the operator has already gathered — and answering it - * the same way would throw those away to show them the first page they saw - * twenty rows ago. The refusal is identical; the right response is not. + * link naming a page that will not open costs nothing: there is nothing on screen + * yet, and page one of its filters is a complete answer. A page refused while an + * operator is already reading rows costs those rows — and answering it the same + * way would throw them away to show the first page again. The refusal is + * identical; the right response is not. + * + * IT NAMES NO DIRECTION, and that is a correction rather than a preference. + * THREE controls now reach this sentence — `Load more`, `Next` and `Previous` — + * so "the page after them could not be added" described a request an operator + * pressing `Previous` never made. What is true of all three is that the page + * ASKED FOR would not open. * * SO THE ROWS STAY AND THE PAGING STOPS. What was already loaded is still true — - * nothing about it is disproved by a later page being unavailable — so it stays + * nothing about it is disproved by another page being unavailable — so it stays * on screen, the retry's page-one rows are discarded rather than merged into it, - * and the only thing withdrawn is the offer to continue. The count line keeps its - * "loaded so far" hedge, because there IS more out there and this render still - * knows it. + * and the only thing withdrawn is the ability to move. The count line keeps its + * "loaded so far" hedge where it had one, because there IS more out there and + * this render still knows it. * * NO CAUSE, same doctrine as {@link CURSOR_RESET_DESCRIPTION}: a stale token, an * expired session and a settings read that blinked are one value by the time they * reach a screen. And no attempt at a fix the operator did not ask for — the two - * things that restart a scan honestly are a filter and a reload, so those are + * things that restart paging honestly are a filter and a reload, so those are * what it names. */ export const PAGING_STOPPED_TITLE = "Paging stopped here"; export const PAGING_STOPPED_DESCRIPTION = - "The rows already loaded are unaffected and still on screen; the page after them could not be added. Apply a filter or reload to start a fresh scan."; + "The rows already on screen are unaffected; the page that was asked for could not be opened. Apply a filter or reload to start again."; + +/** + * WHERE THE OPERATOR IS IN A KEYSET SCAN — a CLIENT-SIDE STACK of cursors. + * + * WHY A STACK AND NOT A QUERY. Keyset paging is one-directional by construction: + * a cursor names "everything after this row", and there is no token for + * "everything before it". The obvious fix is a reverse keyset read — flip the + * ordering, take a page, reverse it back — and it was considered and DECLINED. + * It is a second query shape in the store, a second index consideration, and a + * second set of edge cases at the boundaries, bought to answer a question the + * browser can already answer exactly: the cursors of the pages the operator + * walked through are cursors the SERVICE ISSUED, and going back is replaying + * one. Exact, free, and no server work. + * + * WHAT IS IN IT. `cursors` are the tokens each page after the first was fetched + * with, in visit order, so the last entry is the page on screen and the entry + * before it is where `Previous` goes. Page one is the ABSENCE of a cursor and + * therefore the absence of an entry — which is why popping the last one lands + * there with nothing on the wire. + * + * WHY `grounded` IS SEPARATE FROM THE DEPTH. A stack one deep can mean two + * different things: the operator pressed `Next` once (they are on page two), or + * they arrived on an address naming a page (they are on page ?). `grounded` + * records which — whether the walk started at page one — and it is the whole + * reason {@link pageNumber} can refuse to answer instead of inventing "page 2" + * for a link to page 40. It is also what stops `Previous` popping a deep link's + * single entry: that pop would land page one, which is not the page before this + * one. + */ +export interface PageTrail { + /** The cursors of the pages after the first, in the order they were visited. + * The last is the page on screen. */ + readonly cursors: readonly string[]; + /** Did this walk start at page one? Only then is the depth a page number. */ + readonly grounded: boolean; +} + +/** Page one, with nothing behind it — a fresh list, and where a filter apply + * puts every list. */ +export const FIRST_PAGE: PageTrail = { cursors: [], grounded: true }; + +/** + * The stack an ADDRESS produces, which is the one case that is not grounded. + * + * An absent value is page one, and so is an empty one, for the same reason + * {@link seedCursor} treats them alike: `?cursor=` is a trimmed or stale link + * rather than a request for the empty token. Anything else is a page this list + * did not walk to — it can be paged forward from and returned to, and it cannot + * be numbered. + */ +export function seedTrail(cursor: string | undefined): PageTrail { + return cursor === undefined || cursor.length === 0 + ? FIRST_PAGE + : { cursors: [cursor], grounded: false }; +} + +/** + * One page forward. Both controls that advance the page push here — `Next`, + * which replaces the rows, and `Load more`, which keeps them: they disagree + * about the window, never about the position. + * + * A REPEATED CURSOR IS NOT A PAGE, and this refuses it rather than trusting the + * caller not to produce one. The screens make the same click unavailable while a + * request is in flight, but "unavailable" is a rendered state and this is an + * invariant: two presses resolved inside one React batch, a synthetic double + * event, or a service that answers two consecutive pages with the same + * `nextCursor` would each push the same token twice and leave the stack one + * deeper than the pages actually walked — which shows up as a page NUMBER that + * is quietly wrong, the one defect a pager exists to avoid. Idempotence on the + * top of the stack costs one comparison and makes the guard unnecessary rather + * than load-bearing. + */ +export function pushedPage(trail: PageTrail, cursor: string): PageTrail { + if (trail.cursors.at(-1) === cursor) return trail; + return { cursors: [...trail.cursors, cursor], grounded: trail.grounded }; +} + +/** + * One page back: the stack to keep, and the cursor to fetch it with. + * + * `undefined` IS PAGE ONE, not "no answer" — popping the last entry off a + * grounded stack leaves nothing, and nothing is exactly what page one is asked + * for with. + * + * TOTAL, NOT PARTIAL. A stack with nowhere to go answers with itself and stays + * put, so the controls' guard ({@link hasPreviousPage}) is what OFFERS the act + * rather than what makes it safe. A helper that threw here, or that quietly + * invented page one for a deep link, would make the guard load-bearing and the + * bug it prevents invisible. + */ +export function poppedPage(trail: PageTrail): { + readonly trail: PageTrail; + readonly cursor: string | undefined; +} { + if (!hasPreviousPage(trail)) return { trail, cursor: trail.cursors.at(-1) }; + const cursors = trail.cursors.slice(0, -1); + return { trail: { cursors, grounded: trail.grounded }, cursor: cursors.at(-1) }; +} + +/** Which page this is, 1-based — or `undefined` when the walk did not start at + * page one and the number is therefore not knowable. See {@link PageTrail}. */ +export function pageNumber(trail: PageTrail): number | undefined { + return trail.grounded ? trail.cursors.length + 1 : undefined; +} + +/** + * Is there a page to go BACK to? + * + * A grounded stack answers yes as soon as it has one entry: popping it lands + * page one, which is a real page. An UNGROUNDED one needs two — the deepest + * entry is the address's own page, and popping it would land page one, which is + * not the page before it. + */ +export function hasPreviousPage(trail: PageTrail): boolean { + return trail.grounded ? trail.cursors.length > 0 : trail.cursors.length > 1; +} + +/** One pager control: what it says, whether it can be used, and — when it + * cannot — why. Rendered by `PagerButton` in `ui.tsx`, which draws this and + * decides nothing. */ +export interface PagerControl { + readonly label: string; + readonly unavailable: boolean; + /** + * The reason it is dimmed, or the cost of pressing it — a sentence either + * way, and always one this tier can stand behind. + * + * Absent while a request is merely in flight: "busy" is not a place, and + * naming it would put an explanation on a control that is about to be usable + * again. + */ + readonly title: string | undefined; +} + +export interface PagerView { + readonly visible: boolean; + readonly previous: PagerControl; + readonly next: PagerControl; + /** `Page 2 of 6` · `Pages 2–3 of 6` — or with either half an em dash. + * `undefined` when neither half is known, because "Page — of —" is a line + * that says nothing. Composed by `pagePositionLine` in + * `@otta-sh/admin-presentation`. */ + readonly position: string | undefined; +} + +/** + * THE WHOLE PAGER DECIDED IN ONE PLACE, so two React lists draw it and neither + * decides it. + * + * WHERE THE REST OF THE PAGER LIVES, since it is deliberately split across three + * files: the STACK is {@link PageTrail} above; the WORDS and the arithmetic are + * `pagePositionLine`, `pageCount` and the four title constants in + * `@otta-sh/admin-presentation`'s `list-outcome.ts`; the MARKUP is `PagerButton` + * in `ui.tsx`. This function is the seam that turns the first into the second so + * the third has nothing left to decide. + * + * `M` IS DERIVED, NEVER FETCHED. The service already counts the filtered set + * alongside the page it returns, and the plugin already sends the keyset limit + * it paged by, so the page count is arithmetic over two values this render is + * holding — see `pageCount`. The `total` handed in must be the one the count + * line ACTUALLY STATED (`listOutcome`'s `statedTotal`), not the raw payload + * figure: that is what stops a page count appearing under a caption that + * withheld the very number it was derived from. + * + * THE LAST PAGE IS THE PAGE COUNT — with one exception that is the whole reason + * this is not a one-liner. A render whose response carried no next cursor has + * DIRECT evidence of standing on the last page, and that outranks arithmetic + * over two statements taken at different moments. But when the arithmetic says + * there are MORE pages than the one being stood on, the two disagree outright, + * and answering "Page 6 of 6" beside a count line reading "200 orders" would + * pick a winner the render has no grounds to pick. It dashes instead: an + * absence, rather than either of two figures that cannot both be true. + * + * WITHDRAWN IS THE CALLER'S WORD. A failure card and the paging-stopped state + * both take the whole control away — the list knows about those, this function + * does not — and everything else here is about whether there is anywhere to go. + * + * VISIBLE MEANS "THIS LIST IS PAGED", not "there is a live control". A deep link + * to the LAST page can go neither forward nor back, and hiding the pager there + * would answer the one question the operator arrived with — where am I? — by + * removing the only thing that could say. Any cursor in the stack means paging + * happened, so the position stays on screen with both controls dimmed and + * explained. + */ +export function pagerView(opts: { + readonly trail: PageTrail; + readonly hasNext: boolean; + /** Rows on screen, which is what a `total` is sanity-checked against. */ + readonly rows: number; + /** The count line's OWN figure — `listOutcome`'s `statedTotal`. */ + readonly total?: number; + readonly pageSize?: number; + /** How many responses the rows on screen were merged from. Above one, the + * position states the window rather than its last page. */ + readonly span?: number; + readonly busy: boolean; + readonly withdrawn: boolean; +}): PagerView { + const index = pageNumber(opts.trail); + const derived = pageCount(opts.rows, { + ...(opts.total !== undefined ? { total: opts.total } : {}), + ...(opts.pageSize !== undefined ? { pageSize: opts.pageSize } : {}), + }); + const pages = + !opts.hasNext && index !== undefined + ? derived !== undefined && derived > index + ? undefined + : index + : derived; + const canPrevious = hasPreviousPage(opts.trail); + const accumulated = opts.span !== undefined && opts.span > 1; + return { + // ANY CURSOR IN THE STACK MEANS THIS LIST IS PAGED — see the note above. + visible: !opts.withdrawn && (opts.hasNext || opts.trail.cursors.length > 0), + previous: { + label: PREVIOUS_PAGE_LABEL, + unavailable: opts.busy || !canPrevious, + title: canPrevious + ? undefined + : opts.trail.grounded + ? PREVIOUS_AT_START_TITLE + : PREVIOUS_UNWALKED_TITLE, + }, + next: { + label: NEXT_PAGE_LABEL, + unavailable: opts.busy || !opts.hasNext, + // THE COST IS STATED BEFORE THE CLICK, not discovered after it. Paging on + // from an accumulated scan shows the next page alone, so the pages the + // operator gathered are released — the one thing about this pager that + // takes something away, and the one place it can be said in time. + title: !opts.hasNext ? NEXT_AT_END_TITLE : accumulated ? NEXT_RELEASES_SCAN_TITLE : undefined, + }, + position: pagePositionLine({ + ...(index !== undefined ? { index } : {}), + ...(pages !== undefined ? { pages } : {}), + ...(opts.span !== undefined ? { span: opts.span } : {}), + }), + }; +} + +/** + * THE STACK, AS SOMETHING A HISTORY ENTRY CAN HOLD. + * + * WHY THE ADDRESS IS NOT ENOUGH. A URL carries ONE cursor — the page being shown + * — because that is what makes a link shareable, and a link that replayed a walk + * would be a different feature. But a history ENTRY is not a link: it is this + * browser's private record of somewhere this operator already stood, and + * `history.state` exists precisely to carry what the address cannot. Without it, + * every Back landed on a page whose stack had been thrown away, so a walked-to + * page came back ungrounded — the position went to a dash and `Previous` dimmed, + * two presses into a scan, for no reason the operator could see. + * + * IT IS PARSED DEFENSIVELY, like a URL, because it is the same kind of input: + * `history.state` survives reloads, is written by whatever else shares this + * document, and may have been serialized by an older build of this console. A + * shape that does not match degrades to "no stack", which is exactly the + * deep-link behaviour — legible, and never a thrown error inside a `popstate` + * listener. + */ +export const PAGE_STATE_KEY = "ottaPage"; + +/** The entry's record of the walk, in the plainest shape `structuredClone` can + * carry. */ +export function trailState(trail: PageTrail): { cursors: string[]; grounded: boolean } { + return { cursors: [...trail.cursors], grounded: trail.grounded }; +} + +/** + * THE ONE WAY THIS CONSOLE BUILDS A HISTORY ENTRY'S STATE. + * + * FIVE WRITERS, ONE SHAPE. Each screen writes `history.state` from five places — + * a page change, a filter or tab change, the drill-in push, the drill-out + * replace, and (on Pricing & inventory) the unsaved-work guard's re-push — and + * every one of them used to compose an object literal of its own. That is how + * the stack went missing from three of them: a key added for one writer is + * silently absent from the other four, and the loss only shows up two + * traversals later as a pager that has forgotten where it is. + * + * IT MERGES, IT DOES NOT CLOBBER. The base is whatever the entry already holds, + * so a writer that only means to say "no record is open" cannot take the walk + * with it, and anything the host admin put on the entry survives all five. + * `patch` states only what this writer actually decided; `trail` is optional + * because two of the writers genuinely have no opinion about the page and must + * leave whatever is there alone. + */ +export function entryState( + current: unknown, + patch: Record, + trail?: PageTrail, +): Record { + const base = + typeof current === "object" && current !== null ? (current as Record) : {}; + return { + ...base, + ...patch, + ...(trail !== undefined ? { [PAGE_STATE_KEY]: trailState(trail) } : {}), + }; +} + +/** The walk an entry recorded, or `null` when it recorded none. `null` is not a + * failure: an entry pushed by the host, or by a build of this console older + * than the field, simply has nothing to say, and the caller falls back to + * seeding from the address. */ +export function readTrailState(state: unknown): PageTrail | null { + if (typeof state !== "object" || state === null) return null; + const raw = (state as Record)[PAGE_STATE_KEY]; + if (typeof raw !== "object" || raw === null) return null; + const { cursors, grounded } = raw as { cursors?: unknown; grounded?: unknown }; + if (!Array.isArray(cursors) || typeof grounded !== "boolean") return null; + if (!cursors.every((entry) => typeof entry === "string" && entry.length > 0)) return null; + return { cursors: [...(cursors as string[])], grounded }; +} + +/** + * WHY A PAGE CHANGED, which decides what it does to the history stack. + * + * THE TWO WERE ONE VALUE AND THAT WAS A BUG. The screens read "no cursor" as + * "the list is correcting an address that would not open" and REPLACED the entry + * — right for a refused deep link, which must not bury the entry the operator is + * standing on under one they never asked for. Then `Previous` onto page one + * started producing the same "no cursor", and an operator's deliberate step + * backwards silently overwrote the entry they had stepped from. One value cannot + * mean both "the operator went somewhere" and "the screen fixed something", so + * it does not have to: the intent is stated. + */ +export type PageChangeKind = "navigate" | "correct"; + +/** What the list tells the screen when the page it is showing changes. The list + * states what happened; the screen decides what that does to the address and to + * the history stack. */ +export interface PageChange { + /** The token the address should name, or `undefined` for page one. */ + readonly cursor: string | undefined; + /** The stack that produced it, for the entry's own state. */ + readonly trail: PageTrail; + readonly kind: PageChangeKind; +} diff --git a/packages/admin-react/src/orders/orders-list.tsx b/packages/admin-react/src/orders/orders-list.tsx index e59e265..982dcf9 100644 --- a/packages/admin-react/src/orders/orders-list.tsx +++ b/packages/admin-react/src/orders/orders-list.tsx @@ -39,11 +39,12 @@ import { LOAD_MORE_LABEL, ORDERS_EMPTY, ORDERS_LIST_INTRO, - ORDERS_LOAD_MORE_FAILED_TITLE, + ORDERS_PAGE_FAILED_TITLE, ORDERS_NOUN, ORDERS_NO_MATCH, ORDERS_SEARCH_LABEL, ORDERS_STALE_CLEARED_NOTE, + PAGER_LABEL, RETRYING_LABEL, RETRY_LABEL, buyerReferenceText, @@ -58,11 +59,20 @@ import * as React from "react"; import { CURSOR_RESET_DESCRIPTION, CURSOR_RESET_TITLE, + FIRST_PAGE, PAGING_STOPPED_DESCRIPTION, PAGING_STOPPED_TITLE, + askedForPage, continuationCursor, mergeById, + pagerView, + poppedPage, + pushedPage, seedCursor, + seedTrail, + type PageArrival, + type PageChange, + type PageTrail, type PendingCursor, } from "../accumulate.js"; import { @@ -80,6 +90,7 @@ import { Field, Group, Notice, + PagerButton, StatusPill, Table, buttonStyle, @@ -220,13 +231,16 @@ interface LoadedPage extends OrdersResponse { export function nextPage( current: LoadedPage | null, incoming: OrdersResponse, - continuation: boolean, + arrival: PageArrival, ): LoadedPage { - if (!continuation) return { ...incoming, firstPage: true, pages: 1 }; - // A continuation with nothing to continue is not reachable from this screen - // — the cursor is cleared whenever the page state is — but it is still a - // render that does NOT start at the first page, and says so. - if (current === null) return { ...incoming, firstPage: false, pages: 1 }; + if (arrival === "reset") return { ...incoming, firstPage: true, pages: 1 }; + // A WINDOW THAT MOVED, or one with nothing to merge into. Either way the page + // stands on its own and does NOT start at the first page: `replace` is how a + // pager step and a deep link arrive, and a `current` of `null` is an `extend` + // with no accumulation behind it. + if (arrival === "replace" || current === null) { + return { ...incoming, firstPage: false, pages: 1 }; + } return { ...incoming, orders: mergeById(current.orders, incoming.orders, (order) => order.id), @@ -235,14 +249,23 @@ export function nextPage( }; } -/** A load that came back a refusal. `continuation` records WHICH request failed - * — a first page, or a page behind one that already succeeded — because that - * is what decides whether the rows on screen are disproved by the failure or - * untouched by it. */ +/** + * A load that came back a refusal. + * + * `paging` RECORDS WHICH REQUEST FAILED — a page the operator MOVED to, or a + * fresh load — because that is what decides whether the rows on screen are + * disproved by the failure or untouched by it. + * + * IT IS NOT "DID A CURSOR GO OUT". That was the first cut, and it was wrong in + * exactly one place, which happened to be the one that destroys work: `Previous` + * onto page one sends no cursor, so a failure there read as a fresh load and + * cleared a screenful of rows that were still perfectly true. See + * {@link askedForPage}. + */ export interface OrdersFailure { readonly title: string; readonly description: string; - readonly continuation: boolean; + readonly paging: boolean; } /** @@ -273,11 +296,8 @@ export function clearAnswer(page: LoadedPage | null): LoadedPage | null { * It is a function rather than two lines inside the effect so that the branch * is a value a test can read — the same reason `clearAnswer` is one. */ -export function pageAfterFailure( - page: LoadedPage | null, - continuation: boolean, -): LoadedPage | null { - return continuation ? page : clearAnswer(page); +export function pageAfterFailure(page: LoadedPage | null, paging: boolean): LoadedPage | null { + return paging ? page : clearAnswer(page); } /** What a failure leaves on the screen, and what the card over it says. */ @@ -289,7 +309,8 @@ export interface OrdersFailureCard { readonly answerVisible: boolean; /** The filter bar and the filter summary. */ readonly filtersVisible: boolean; - /** Rendered where `Load more` was, rather than above the list. */ + /** Rendered in the paging bar, where the control that issued the failed + * request was, rather than above the list. */ readonly inline: boolean; /** Focus was inside a row that no longer exists. */ readonly focusRetry: boolean; @@ -309,10 +330,11 @@ export interface OrdersFailureCard { * carries the server's own words plus the sentence that says the rows went * and why; the filter bar stays, because the operator's typed filters are * input rather than answer. - * - **Partial.** A page BEHIND a successful one failed. Every accumulated row - * and the count stand, and the server's whole-collection title is dropped — - * the rows above disprove it. What failed was the next page, and the card - * says so from where `Load more` was. + * - **Partial.** A PAGE MOVE failed under rows that succeeded — in either + * direction, and including a `Previous` that put no cursor on the wire. Every + * row on screen and the count stand, and the server's whole-collection title + * is dropped: the rows disprove it. What failed was one page, and the card + * says so from the paging bar, where the control that asked for it was. */ export function ordersFailureCard(failure: OrdersFailure, everLoaded: boolean): OrdersFailureCard { if (!everLoaded) { @@ -326,10 +348,10 @@ export function ordersFailureCard(failure: OrdersFailure, everLoaded: boolean): focusRetry: false, }; } - if (failure.continuation) { + if (failure.paging) { return { kind: "partial", - title: ORDERS_LOAD_MORE_FAILED_TITLE, + title: ORDERS_PAGE_FAILED_TITLE, description: failure.description, answerVisible: true, filtersVisible: true, @@ -414,6 +436,7 @@ export function OrdersList({ onOpen, initialFilter = {}, initialCursor, + initialTrail, onFilterChange, onCursorChange, }: { @@ -444,6 +467,21 @@ export function OrdersList({ * field, not the normal case. */ initialCursor?: string; + /** + * THE WALK THE HISTORY ENTRY RECORDED, when it recorded one. + * + * A URL carries one cursor, which is what makes a link shareable — but a + * history entry is this browser's private record of somewhere this operator + * already stood, and it can carry the stack the address cannot. Without it, + * Back onto a page the operator had walked to came back UNGROUNDED: the + * position fell to a dash and `Previous` dimmed, two presses into a scan, for + * no reason visible on screen. + * + * ABSENT IS A DEEP LINK, and that is the honest default — a pasted address, a + * fresh tab, an entry pushed by something that is not this screen. See + * {@link seedTrail}. + */ + initialTrail?: PageTrail; /** Announced whenever the applied filter changes, for the screen to write to * the URL. The list never touches history itself: one writer. */ onFilterChange?: (filter: OrdersFilter) => void; @@ -458,7 +496,7 @@ export function OrdersList({ * passing a fresh arrow on every render would re-run the effect on every * render — a refetch loop. The screens wrap it in `useCallback`. */ - onCursorChange?: (cursor: string | undefined) => void; + onCursorChange?: (change: PageChange) => void; }): React.ReactElement { const [applied, setApplied] = React.useState(initialFilter); const [draft, setDraft] = React.useState(initialFilter); @@ -484,6 +522,21 @@ export function OrdersList({ const [cursor, setCursor] = React.useState | null>(() => seedCursor(initialFilter, initialCursor), ); + /** + * THE PAGES WALKED TO GET HERE — the client-side stack `Previous` pops. + * + * SEEDED FROM THE SAME ADDRESS THE CURSOR IS, and ungrounded when that address + * named a page: a link says WHICH page, never HOW MANY came before it, so this + * mount can go forward and come back without ever being entitled to print a + * page number. See {@link PageTrail}. + * + * IT MOVES ON THE CLICK, exactly as the cursor and the address do, and is not + * rewound by a refusal — a failed page withdraws the pager rather than + * pretending the operator never asked. + */ + const [trail, setTrail] = React.useState( + () => initialTrail ?? seedTrail(initialCursor), + ); /** The address named a page that would not open, and this render is the first * page of its filters instead — the SEEDED path only. See the effect. */ const [cursorReset, setCursorReset] = React.useState(false); @@ -551,7 +604,14 @@ export function OrdersList({ // `continuationCursor`): one belonging to a filter that has since been // replaced is not sent, and this request is the new filter's first page. const from = continuationCursor(cursor, applied); - const continuation = from !== undefined; + // DID THE OPERATOR ASK FOR THIS PAGE? Not "is there a cursor on the wire" — + // `Previous` onto page one sends none and is still a move. See + // {@link askedForPage}. + const paging = askedForPage(cursor, applied); + // WHICH OF THE TWO ADVANCING CONTROLS ISSUED IT. Only `Load more` extends + // the rows on screen; a pager step and a deep link name a page that stands + // on its own. See `PendingCursor.extend`. + const extending = paging && cursor?.extend === true; setBusy(true); void fetchOrders(applied, from).then((result) => { if (cancelled) return; @@ -578,10 +638,11 @@ export function OrdersList({ * and everything else arrives here — as a failure that leaves the cursor * exactly where it was, in state and in the address. */ - setFailure({ title: result.title, description: result.description, continuation }); - // A FIRST PAGE THAT FAILED DISPROVES WHAT IS ON SCREEN; a page behind - // one that succeeded does not. Only the first case clears. - setPage((current) => pageAfterFailure(current, continuation)); + setFailure({ title: result.title, description: result.description, paging }); + // A FRESH LOAD THAT FAILED DISPROVES WHAT IS ON SCREEN; a page the + // operator moved to does not — in either direction. Only the first + // case clears. + setPage((current) => pageAfterFailure(current, paging)); return; } setFailure(null); @@ -613,9 +674,22 @@ export function OrdersList({ if (rejected) { skipRefetchAfterReset.current = true; setCursor(null); - onCursorChange?.(undefined); + // A CORRECTION, NEVER A JOURNEY: the entry the operator is standing on + // is rewritten rather than buried under one they never asked for. The + // stack it records is page one's, which is what a reload of the + // corrected address would produce — mid-scan that deliberately differs + // from the in-memory stack, which still describes the rows on screen. + onCursorChange?.({ cursor: undefined, trail: FIRST_PAGE, kind: "correct" }); if (midScan) setPagingStopped(true); - else setCursorReset(true); + else { + setCursorReset(true); + // THE ROWS BELOW REALLY ARE PAGE ONE, so the stack has to say so — + // otherwise the position would keep the unknowable page the address + // asked for while the screen showed the first one. The mid-scan + // branch deliberately does NOT reset: those rows are still the pages + // the operator gathered, and the pager is withdrawn there anyway. + setTrail(FIRST_PAGE); + } } if (midScan) { // THE ONE RESPONSE THIS SCREEN THROWS AWAY. Every row in it is real and @@ -628,6 +702,33 @@ export function OrdersList({ // F24: MERGE, NEVER ASSIGN. See `nextPage` — the functional form is // required, not stylistic, because the rows it merges into are the ones // in state at the moment the response lands. + /* + * WHAT THE WIRE SAYS THIS RESPONSE IS — and it is the WIRE, not the + * operator's intent, that decides. + * + * A REQUEST THAT CARRIED NO CURSOR IS PAGE ONE, whoever asked for it. A + * filter apply, a first mount and a `Previous` off the bottom of the + * stack all send the same empty request and all come back with the same + * thing: the first page, under the current predicate, answered + * authoritatively. Calling the last of those a `replace` — which the + * first cut did, because the OPERATOR had asked for a page — was wrong in + * two directions at once. It captioned a render that IS the first page as + * `firstPage: false`, which takes the whole-collection empty copy away and + * puts the "on this page" hedge on a count the render could prove; and on + * Pricing & inventory it carried a latch forward over a response entitled + * to clear it, so a banner raised by a settings blip could never go away + * — while a blip happening ON that request could not raise one. + * + * THE FAILURE CLASSIFICATION IS A SEPARATE QUESTION and stays on `paging`. + * "Is this response page one" and "do the rows on screen survive this + * request failing" are genuinely different questions, and keeping them + * apart is what makes answering the first one straight off the wire safe. + * + * A REFUSED CURSOR IS PAGE ONE TOO: the plugin already performed the + * recovery, so these are the first page's rows however they were asked for. + */ + const arrival: PageArrival = + rejected || from === undefined ? "reset" : extending ? "extend" : "replace"; setPage((current) => nextPage( current, @@ -637,11 +738,7 @@ export function OrdersList({ total: result.total, vocabulary: result.vocabulary, }, - // A REFUSED CURSOR MAKES THIS A RESET, whatever the request was: the - // rows are page one's, so merging them onto an accumulation (or - // inheriting `firstPage: false` from a continuation that never - // happened) would caption a first page as the middle of a scan. - continuation && !rejected, + arrival, ), ); }); @@ -736,6 +833,13 @@ export function OrdersList({ setApplied(next); setDraft(next); setCursor(null); + // THE STACK RESETS WITH THE PREDICATE, and this is not tidiness. A cursor + // is only meaningful against the filter it was issued under, so a stack that + // survived an apply would hand `Previous` a token from the set the operator + // just left — a page of the old predicate, or (once the service notices the + // disagreement) a refusal and a bounce back to page one. Page one of the new + // filter has nothing behind it, and the pager must say so. + setTrail(FIRST_PAGE); // THE RESET NOTICE IS ABOUT THE ARRIVAL, so it goes the moment the operator // asks for something themselves. Left standing it would explain a link that // no longer has anything to do with what is on screen. @@ -764,6 +868,113 @@ export function OrdersList({ retrying, }); + /** + * THE PAGER, decided in `pagerView` and only drawn here. + * + * WITHDRAWN WHEREVER `Load more` IS. A failure has already replaced the offer + * to page with a Retry for the request that failed, and the paging-stopped + * state has just taken the page out of the ADDRESS — leaving `Previous` + * standing there would offer to step back relative to a position the screen + * disowned one line above. Both states leave the rows exactly where they are; + * it is only the paging that goes. + */ + const pager = pagerView({ + trail, + hasNext, + rows: orders.length, + // THE COUNT LINE'S OWN FIGURE, not the payload's. `listOutcome` is the one + // place a `total` is validated and, on some scopes, withheld; feeding the + // raw one here would let a page count appear under a caption that refused + // the very number it was derived from. + ...(outcome.statedTotal !== undefined ? { total: outcome.statedTotal } : {}), + // HOW MANY PAGES ARE ON SCREEN AT ONCE. Above one the position states the + // window (`Pages 2–3 of 6`) rather than only where it ends, which is all + // "Page 3" would say about fifty rows beginning at page two. + span: page?.pages ?? 1, + // THE PAGE SIZE IS ON THE WIRE ALREADY — the plugin sends the keyset limit + // it pages by, so `M` costs no request. A service that omits it leaves the + // page count an em dash rather than a guess. + ...(vocabulary !== undefined ? { pageSize: vocabulary.pageLimit } : {}), + busy, + withdrawn: page === null || !answerVisible || failure !== null || pagingStopped, + }); + /** The same withdrawal, on the control that was already gated this way. */ + const loadMoreVisible = page?.nextCursor != null && failure === null && !pagingStopped; + + /** + * One page forward, from whichever control asked. Both push the same stack; + * they disagree only about whether the rows above stay. + * + * IT READS `trail` OUT OF THE CLOSURE, which is the exception to the functional + * `setPage` form a few lines up, and the difference is WHEN the value is + * needed. `setPage` runs when a RESPONSE lands, which may be several renders + * after the request went out, so it must see whatever is in state then. This + * runs inside the click, on the render the operator is looking at, and the + * same value has to reach three places at once — the state, the history entry, + * and the cursor — so reading it once is what keeps the three in step. A + * functional update here would hand the entry a stack the state had not + * committed to. + * + * THE BUSY GUARD IS STILL LOAD-BEARING, and {@link pushedPage}'s idempotence + * does not replace it: repeating a cursor cannot deepen the STACK, but two + * presses resolved before the effect runs would still push two history + * ENTRIES for one page, and a duplicate entry is not something this tier can + * take back. + */ + const goForward = (extend: boolean) => { + const value = page?.nextCursor; + if (value == null) return; + setCursor({ filter: applied, value, ...(extend ? { extend: true } : {}) }); + const moved = pushedPage(trail, value); + setTrail(moved); + setCursorReset(false); + // BUSY IS THE CLICK'S, NOT THE EFFECT'S — the same rule `apply` follows. The + // effect that issues the request runs after this commit, so without this + // there is one render in which the position has already moved and both pager + // controls are still live: a second press would push the SAME cursor again + // and leave the stack one deeper than the pages actually walked. + setBusy(true); + // The page goes in the address, and the screen is the only writer — this + // states what happened, it does not navigate. `navigate`, because the + // operator went somewhere: the entry is pushed, and it carries the stack + // that produced it so a later Back lands here still knowing where it is. + onCursorChange?.({ cursor: value, trail: moved, kind: "navigate" }); + }; + + /** + * One page back, by REPLAYING the cursor the stack popped. + * + * IT RE-REQUESTS RATHER THAN RESTORING. The stack holds cursors, not pages, and + * that is the choice rather than an implementation detail: a re-request under a + * token the service already issued is exact and answers with the collection as + * it stands NOW, while replaying rows kept in memory would show a page that may + * be minutes stale — and would disagree with a reload of the very same address, + * which fetches. Holding N pages of rows to avoid one round trip would also + * grow without bound down a long scan. + * + * POPPING THE LAST ENTRY IS PAGE ONE, cursor and all: the request goes out + * without a token and the address is corrected through the one path this list + * announces its page on. + */ + const goBack = () => { + const { trail: rest, cursor: target } = poppedPage(trail); + setTrail(rest); + // A CURSOR OBJECT EVEN WHEN THE PAGE IS ONE. `null` would mean "no page was + // asked for" and would make a failure here clear the rows — see + // {@link askedForPage}. Page one is asked for by sending no token, which is + // a `value` of `undefined`, not by having no request. + setCursor({ filter: applied, value: target }); + setCursorReset(false); + // See `goForward`: the controls go unavailable on the click rather than on + // the effect, so the commit in between cannot take a second press. + setBusy(true); + // NAVIGATE, NOT CORRECT — the correction that shares this shape is the + // refused-cursor recovery, which REPLACES the entry. An operator stepping + // back deliberately went somewhere, and overwriting the entry they stepped + // from would delete the page they just left from their own history. + onCursorChange?.({ cursor: target, trail: rest, kind: "navigate" }); + }; + // THE FAILURE IS NOT CLEARED HERE. Clearing it on the click rather than on // the response would flash the stale answer back for the length of the // request — the exact defect being fixed. The response clears it. @@ -1116,9 +1327,11 @@ export function OrdersList({ )} {/* - THE CONTINUATION FAILURE RENDERS WHERE `Load more` WAS, and replaces - it: the button and the card would otherwise offer the same request - twice, and the one that failed is the one the operator just pressed. + A FAILED PAGE MOVE RENDERS WHERE THE PAGING BAR WAS, and replaces the + whole bar: its controls and this card would otherwise offer the same + request twice, and the one that failed is the one the operator just + pressed. It replaces `Previous`/`Next` as much as `Load more` — all + three land here. */} {card !== null && card.inline && (
@@ -1133,8 +1346,8 @@ export function OrdersList({ )} {/* - PAGING STOPPED MID-SCAN — rendered where `Load more` was, because it is - what replaces that control. NO FOCUS MOVE, unlike the seeded-link + PAGING STOPPED MID-SCAN — rendered where the paging bar was, because it + is what replaces every control in it. NO FOCUS MOVE, unlike the seeded-link notice: the operator is mid-interaction with their hands on the page, and taking focus off what they were doing to announce a control that simply is not there any more would be the more disruptive answer to the @@ -1151,26 +1364,64 @@ export function OrdersList({
)} - {page?.nextCursor != null && failure === null && !pagingStopped && ( -
- + {/* + THE TWO WAYS FORWARD, IN ONE BAR — and they are two acts, not two + spellings of one. `Previous`/`Next` MOVE a one-page window and are how + an operator navigates a long list; `Load more` EXTENDS the window and is + how they build a scan to read in one go. Both advance the same position, + so the page number below counts either — what differs is whether the rows + above stay. + */} + {(pager.visible || loadMoreVisible) && ( +
+ {pager.visible && ( + + )} + {loadMoreVisible && ( + + )}
)}
diff --git a/packages/admin-react/src/orders/orders-screen.tsx b/packages/admin-react/src/orders/orders-screen.tsx index 6908bb1..bc359af 100644 --- a/packages/admin-react/src/orders/orders-screen.tsx +++ b/packages/admin-react/src/orders/orders-screen.tsx @@ -31,7 +31,17 @@ import type { OrdersFilter } from "../console-api.js"; import { ORDER_STATES } from "@otta-sh/admin-presentation"; import * as React from "react"; -import { CURSOR_PARAM, cursorQuery, readCursor } from "../accumulate.js"; +import { + CURSOR_PARAM, + FIRST_PAGE, + cursorQuery, + entryState, + readCursor, + readTrailState, + seedTrail, + type PageChange, + type PageTrail, +} from "../accumulate.js"; import { ConsoleStyles } from "../ui.js"; import { OrderDetail } from "./order-detail.js"; import { OrdersList } from "./orders-list.js"; @@ -177,6 +187,13 @@ function currentSearch(): string { return typeof window === "undefined" ? "" : window.location.search; } +/** The entry's own state, which is where the pager's stack lives — see + * {@link PAGE_STATE_KEY}. `null` off the browser, exactly as the address is + * empty there. */ +function currentState(): unknown { + return typeof window === "undefined" ? null : window.history.state; +} + /** * Swap the whole query, keeping the entry. * @@ -185,11 +202,15 @@ function currentSearch(): string { * through every intermediate filter before it ever left the screen, and would * break the single-pushed-entry assumption `← Back to orders` relies on. */ -function replaceQuery(query: string): void { +function replaceQuery(query: string, trail?: PageTrail): void { if (typeof window === "undefined") return; const url = new URL(window.location.href); url.search = query; - window.history.replaceState(window.history.state, "", url); + // THE ENTRY'S OWN RECORD OF THE WALK rides alongside the address — through + // {@link entryState}, which merges rather than clobbers. This function is used + // by filters and tabs too, and a filter change must not silently drop the + // drill-in state a later Back would read. + window.history.replaceState(entryState(window.history.state, {}, trail), "", url); } /** @@ -206,11 +227,17 @@ function replaceQuery(query: string): void { * reads this state (the selection is read off the address, not off * `history.state`), so it is a statement about the entry rather than a channel. */ -function pushQuery(query: string): void { +function pushQuery(query: string, trail: PageTrail): void { if (typeof window === "undefined") return; const url = new URL(window.location.href); url.search = query; - window.history.pushState({ ottaOrder: null }, "", url); + // THE STACK GOES ON THE ENTRY, and this is the whole reason a traversal can + // land on page four still knowing it is page four. The ADDRESS deliberately + // carries one cursor — that is what makes a link shareable, and a link that + // replayed a walk would be a different feature — but a history entry is this + // browser's private note about somewhere this operator already stood, and it + // can hold what a link cannot. + window.history.pushState(entryState(window.history.state, { ottaOrder: null }, trail), "", url); } function readSelectedOrder(): string | null { @@ -219,11 +246,20 @@ function readSelectedOrder(): string | null { return value !== null && value.length > 0 ? value : null; } -function pushSelectedOrder(orderId: string): void { +function pushSelectedOrder(orderId: string, trail: PageTrail): void { if (typeof window === "undefined") return; const url = new URL(window.location.href); url.searchParams.set(ORDER_PARAM, orderId); - window.history.pushState({ ottaOrder: orderId }, "", url); + // THE RECORD'S ENTRY CARRIES THE LIST'S PAGE TOO. A drill-in from page four is + // still page four: the operator opened a record from there, and Back — or a + // reload of the record's own address followed by `Back to orders` — has to + // return them to a list that knows it. Composing this entry without the stack + // is how the pager forgot its position one click away from where it earned it. + window.history.pushState( + entryState(window.history.state, { ottaOrder: orderId }, trail), + "", + url, + ); } /** @@ -260,7 +296,9 @@ function popSelectedOrder(pushed: boolean): void { // address afterwards: a `tab` left behind here would sit on a list URL that // has no tabs and then seed the NEXT record the operator opened. url.searchParams.delete(TAB_PARAM); - window.history.replaceState({ ottaOrder: null }, "", url); + // MERGED, NEVER CLOBBERED: this writer means "no record is open" and nothing + // else, so the entry's page stack is not its to discard. + window.history.replaceState(entryState(window.history.state, { ottaOrder: null }), "", url); } export function OrdersScreen(): React.ReactElement { @@ -280,6 +318,17 @@ export function OrdersScreen(): React.ReactElement { * is what stops a later remount seeding the list from a page it already left. */ const [cursor, setCursor] = React.useState(() => readCursor(currentSearch())); + /** + * THE WALK THIS ENTRY RECORDED, and the reason Back does not lose the pager. + * + * Read from `history.state`, which survives a traversal and a reload; absent + * (a pasted link, a fresh tab, an entry pushed by the host) it falls back to + * seeding from the address, which is the deep-link behaviour. Like the cursor + * it is ONLY EVER A SEED: the list owns where it has paged to once mounted. + */ + const [trail, setTrail] = React.useState( + () => readTrailState(currentState()) ?? seedTrail(readCursor(currentSearch())), + ); const [tab, setTab] = React.useState(() => readOrderTab(currentSearch())); /** Bumped on every `popstate`, and used as the child's `key`: a traversal is * the one moment the URL knows something the mounted child does not, so the @@ -293,14 +342,26 @@ export function OrdersScreen(): React.ReactElement { const pushed = React.useRef(false); /** - * PAGING PUSHES; RETURNING TO PAGE ONE REPLACES. + * NAVIGATION PUSHES; A CORRECTION REPLACES — and the LIST says which, because + * the address cannot. * * A page the operator asked for is somewhere they went, so it earns a history - * entry and Back walks the pages they actually visited. The `undefined` case - * is not a journey in the other direction: it is the list reporting that the - * page the address named would not open AND that page one has since landed, so - * the entry the operator is standing on is corrected in place rather than - * buried under a second one they never asked for. + * entry and Back walks the pages they actually visited. A correction is not a + * journey: the list is reporting that the page the address named would not + * open AND that page one has since landed, so the entry the operator is + * standing on is fixed in place rather than buried under a second one they + * never asked for. + * + * THIS USED TO BE INFERRED FROM "NO CURSOR", AND THAT WAS A BUG. `undefined` + * meant page one, and page one is reached BOTH ways — by the recovery above + * and by an operator pressing `Previous` from page two. Inferring the intent + * from the value made every deliberate step back overwrite the entry it was + * stepping from, deleting the page they had just left from their own history. + * See {@link PageChangeKind}. + * + * EVERY WRITE CARRIES THE STACK. The address holds one cursor, which is what + * makes a link shareable; the entry holds the walk behind it, which is what + * lets a traversal land on page four still knowing it is page four. * * `useCallback` IS REQUIRED, NOT TIDINESS. The list depends on this function * in its fetch effect; a fresh arrow on every render would re-run that effect, @@ -308,11 +369,12 @@ export function OrdersScreen(): React.ReactElement { * — `setCursor` is stable and the rest is module scope — so the empty * dependency list is exact rather than a suppression. */ - const onCursorChange = React.useCallback((next: string | undefined) => { - setCursor(next); - const query = cursorQuery(currentSearch(), next); - if (next === undefined) replaceQuery(query); - else pushQuery(query); + const onCursorChange = React.useCallback((change: PageChange) => { + setCursor(change.cursor); + setTrail(change.trail); + const query = cursorQuery(currentSearch(), change.cursor); + if (change.kind === "correct") replaceQuery(query, change.trail); + else pushQuery(query, change.trail); }, []); React.useEffect(() => { @@ -326,6 +388,10 @@ export function OrdersScreen(): React.ReactElement { // it. A cursor restored through some other channel would race the filter // it belongs to through the one commit where they disagree. setCursor(readCursor(currentSearch())); + // AND THE STACK THE ENTRY CARRIED. Without it a Back onto a page the + // operator had walked to came back as if it had been pasted in: position + // dashed, `Previous` dimmed, two presses into a scan. + setTrail(readTrailState(window.history.state) ?? seedTrail(readCursor(currentSearch()))); setTab(readOrderTab(currentSearch())); setRestore((n) => n + 1); }; @@ -341,15 +407,19 @@ export function OrdersScreen(): React.ReactElement { key={restore} initialFilter={filter} initialCursor={cursor} + initialTrail={trail} onFilterChange={(next) => { // The filter query drops the cursor with the filter it belonged to - // (see `ordersFilterQuery`), so this one write says both things. + // (see `ordersFilterQuery`), so this one write says both things — + // and the entry's stack goes with them, because a stack from the + // previous predicate is exactly what `Previous` must never pop. setCursor(undefined); - replaceQuery(ordersFilterQuery(currentSearch(), next)); + setTrail(FIRST_PAGE); + replaceQuery(ordersFilterQuery(currentSearch(), next), FIRST_PAGE); }} onCursorChange={onCursorChange} onOpen={(orderId) => { - pushSelectedOrder(orderId); + pushSelectedOrder(orderId, trail); pushed.current = true; setSelected(orderId); }} diff --git a/packages/admin-react/src/products/products-list.tsx b/packages/admin-react/src/products/products-list.tsx index 9c93e81..df45c85 100644 --- a/packages/admin-react/src/products/products-list.tsx +++ b/packages/admin-react/src/products/products-list.tsx @@ -59,9 +59,10 @@ import { LOAD_MORE_LABEL, LOW_STOCK_FILTER_DESCRIPTION, LOW_STOCK_FILTER_LABEL, + PAGER_LABEL, PRODUCTS_EMPTY, PRODUCTS_LIST_INTRO, - PRODUCTS_LOAD_MORE_FAILED_TITLE, + PRODUCTS_PAGE_FAILED_TITLE, PRODUCTS_LOW_STOCK_NOUN, PRODUCTS_LOW_STOCK_NO_MATCH, PRODUCTS_NOUN, @@ -86,11 +87,20 @@ import * as React from "react"; import { CURSOR_RESET_DESCRIPTION, CURSOR_RESET_TITLE, + FIRST_PAGE, PAGING_STOPPED_DESCRIPTION, PAGING_STOPPED_TITLE, + askedForPage, continuationCursor, mergeById, + pagerView, + poppedPage, + pushedPage, seedCursor, + seedTrail, + type PageArrival, + type PageChange, + type PageTrail, type PendingCursor, } from "../accumulate.js"; import { @@ -109,6 +119,7 @@ import { Field, Group, Notice, + PagerButton, StatusPill, Table, buttonStyle, @@ -254,38 +265,18 @@ interface LoadedPage extends ProductsResponse { * matches the merchant had already gathered vanished at the exact moment they * asked to see more of them. * - * A CONTINUATION EXTENDS; EVERYTHING ELSE RESETS. `continuation` is "this - * request carried a cursor", which is exactly the request `Load more` (and a - * Retry of it) issues. A filter change clears the cursor before re-fetching, so - * it arrives here as a reset and the previous filter's rows go — and a first - * mount, including one deep-linked to a filtered address, is the same reset. + * THREE ARRIVALS, NOT TWO — see {@link PageArrival}. `extend` is `Load more` + * (and a Retry of it); `replace` is a pager step or a deep link, which move the + * window rather than growing it; everything else is a `reset`, including a + * filter change and a first mount. * * THE CURSOR, THE VOCABULARY AND THE STOCK CONTEXT TAKE THE NEW PAGE'S VALUES; - * the rows merge by id (see {@link mergeById}) and `firstPage` is inherited, - * because a scan that began at the first page still starts there after its - * second page lands. + * on an `extend` the rows merge by id (see {@link mergeById}) and `firstPage` is + * inherited, because a scan that began at the first page still starts there + * after its second page lands. * - * `filterUnavailable` IS THE ONE EXCEPTION, AND IT LATCHES — it is INHERITED - * from the accumulation rather than read off the newest response. Every other - * field describes that response, which is what a single page needs; this one - * describes whether the operator's filter was ever applied to the rows they are - * looking at. A continuation's predicate rode inside the opaque cursor, so the - * plugin reports `false` for every one of them by contract - * (`resolveStockContext`'s decision 3) — there is no incoming `true` to combine - * with, which is why this inherits instead of OR-ing. Taking that `false` at - * face value is how a scan whose FIRST page went out unfiltered, the threshold - * having been unreadable just then, quietly drops the banner at the click of - * `Load more` and starts calling every product in the catalog low-stock. Page - * one's answer stands until the scan resets. - * - * THE `total` GOES WITH IT, and the reason is page one's, not a mixture: the - * continuation's rows were fetched under the SAME (absent) predicate, so they - * are homogeneous — but the count that arrives with them is the count of every - * product, while the operator asked for the low-stock ones. Page one already - * withheld its own total on exactly that ground, so honouring this one would - * jump the caption from a hedged page count to a confident exact number - * underneath a banner saying the filter was skipped. The count falls back to - * what the render can back up on its own. + * `filterUnavailable` IS THE ONE EXCEPTION, AND IT LATCHES ACROSS BOTH KINDS OF + * CONTINUATION — the long argument is at the branch itself. * * `unreadable` DOES NOT LATCH, deliberately. It describes how the newest * response could be RENDERED — whether its own rows carry an on-hand figure — @@ -295,16 +286,63 @@ interface LoadedPage extends ProductsResponse { export function nextPage( current: LoadedPage | null, incoming: ProductsResponse, - continuation: boolean, + arrival: PageArrival, ): LoadedPage { - if (!continuation) return { ...incoming, firstPage: true, pages: 1 }; - if (current === null) return { ...incoming, firstPage: false, pages: 1 }; - const { filterUnavailable } = current.stock; - return { + if (arrival === "reset") return { ...incoming, firstPage: true, pages: 1 }; + /* + * THE LATCH SURVIVES A PAGER STEP, and getting this wrong was the sharpest + * defect the pager introduced. + * + * `filterUnavailable` is not a property of the rows; it is the answer to "was + * the merchant's low-stock filter ever applied to what you are looking at", + * and only PAGE ONE can answer it. Every request carrying a cursor reports + * `false` by contract (`resolveStockContext`'s decision 3), because the + * predicate rode inside the opaque token and the plugin has nothing to + * re-check. So a continuation has NO answer of its own — `false` there means + * "not asked", not "the filter ran". + * + * The first cut inherited the latch only on an `extend`, on the reasoning that + * a replaced window is a single page and a single page speaks for itself. It + * does not: `Next` from a first page whose threshold could not be read sends a + * cursor exactly like `Load more` does, gets the same contractual `false` + * back, and would drop the banner and start captioning every product in the + * catalog as low stock — at the click of a control that has nothing to do with + * filtering. The latch therefore rides on the CONTINUATION, not on the merge. + * + * AND ONLY ON A CONTINUATION. A request that put no cursor on the wire — + * `Previous` off the bottom of the stack as much as a filter apply — is page + * one, answered authoritatively, and arrives as a `reset` above: it may clear + * a stale banner and it may raise a fresh one. Latching there would have been + * the same defect pointing the other way, with a banner nothing could ever + * dismiss. + * + * KNOWN LIMIT, STATED RATHER THAN HIDDEN: a continuation with NOTHING BEHIND + * IT — a page deep-linked straight from an address — has no page-one answer to + * inherit, so it takes the contractual `false` at face value and shows no + * banner even if the threshold is unreadable. The screen cannot do better + * without a second request it has no reason to make: the fact simply is not on + * the wire for that page. It self-corrects the moment the merchant pages back + * to the first page or applies a filter. + * + * THE `total` GOES WITH IT for page one's own reason: the count that arrives + * is the count of every product while the merchant asked for the low-stock + * ones, so honouring it would put a confident exact number under a banner + * saying the filter was skipped. + */ + const filterUnavailable = current?.stock.filterUnavailable ?? incoming.stock.filterUnavailable; + const carried = { ...incoming, - products: mergeById(current.products, incoming.products, (product) => product.productId), stock: { ...incoming.stock, filterUnavailable }, ...(filterUnavailable ? { total: undefined } : {}), + }; + // A WINDOW THAT MOVED, or one with nothing to merge into: the page stands on + // its own, and it does not start at the first page. + if (arrival === "replace" || current === null) { + return { ...carried, firstPage: false, pages: 1 }; + } + return { + ...carried, + products: mergeById(current.products, incoming.products, (product) => product.productId), firstPage: current.firstPage, pages: current.pages + 1, }; @@ -359,11 +397,8 @@ export function clearAnswer(page: LoadedPage | null): LoadedPage | null { * from the cursor the page kept, and its response merges onto the rows that * stayed. Same shape as the Orders list, which had it right. */ -export function pageAfterFailure( - page: LoadedPage | null, - continuation: boolean, -): LoadedPage | null { - return continuation ? page : clearAnswer(page); +export function pageAfterFailure(page: LoadedPage | null, paging: boolean): LoadedPage | null { + return paging ? page : clearAnswer(page); } /** @@ -376,23 +411,23 @@ export function pageAfterFailure( * - A COLD OR STALE failure is about the whole screen: nothing on it is true * any more, so the service's own whole-collection title stands at the top, * above the space the rows used to occupy. - * - A CONTINUATION failure is about ONE REQUEST, and the rows above it are the - * answer to a different one that succeeded. Rendering the service's - * whole-collection refusal over rows that are still on screen states - * something those rows disprove, so the title shrinks to the claim this - * render can back — the NEXT page failed — and it is drawn inline, where - * `Load more` was, because that is the control it replaces. + * - A PAGING failure is about ONE REQUEST, and the rows above it are the answer + * to a different one that succeeded. Rendering the service's whole-collection + * refusal over rows that are still on screen states something those rows + * disprove, so the title shrinks to the claim this render can back — ONE page + * failed, in whichever direction it was asked for — and it is drawn inline, + * where the paging controls were, because that is what it replaces. */ export function failureNotice( failure: { readonly title: string; readonly description: string; - readonly continuation: boolean; + readonly paging: boolean; } | null, ): { readonly title: string; readonly description: string; readonly inline: boolean } | null { if (failure === null) return null; - return failure.continuation - ? { title: PRODUCTS_LOAD_MORE_FAILED_TITLE, description: failure.description, inline: true } + return failure.paging + ? { title: PRODUCTS_PAGE_FAILED_TITLE, description: failure.description, inline: true } : { title: failure.title, description: failure.description, inline: false }; } @@ -421,6 +456,7 @@ export function ProductsList({ onOpen, initialFilter = {}, initialCursor, + initialTrail, onFilterChange, onCursorChange, }: { @@ -439,6 +475,14 @@ export function ProductsList({ * service that omits the field leaves it on the page-scoped hedge. Same * contract, same reasoning, as the Orders list. */ initialCursor?: string; + /** THE WALK THE HISTORY ENTRY RECORDED, when it recorded one. A URL carries + * one cursor, which is what makes a link shareable; a history entry is this + * browser's private record of somewhere this merchant already stood, and can + * carry the stack the address cannot. Without it, Back onto a page they had + * walked to came back UNGROUNDED — the position fell to a dash and `Previous` + * dimmed, two presses into a scan. Absent is a deep link, which is the honest + * default. Same contract as the Orders list. */ + initialTrail?: PageTrail; /** Announced whenever the applied filter changes, for the screen to write to * the URL. The list never touches history itself: one writer. */ onFilterChange?: (filter: ProductsFilter) => void; @@ -448,18 +492,20 @@ export function ProductsList({ * IDENTITY MUST BE STABLE ACROSS RENDERS: it is a dependency of the fetch * effect, so a fresh arrow per render would re-fetch on every render. The * screens wrap it in `useCallback`. */ - onCursorChange?: (cursor: string | undefined) => void; + onCursorChange?: (change: PageChange) => void; }): React.ReactElement { const [applied, setApplied] = React.useState(initialFilter); const [draft, setDraft] = React.useState(initialFilter); const [page, setPage] = React.useState(null); - /** `continuation` records WHICH request failed — a first page, or a page - * behind one that already succeeded — because that is what decides whether - * the rows on screen are disproved by the failure or untouched by it. */ + /** `paging` records WHICH request failed — a page the merchant MOVED to, or a + * fresh load — because that is what decides whether the rows on screen are + * disproved by the failure or untouched by it. Not "did a cursor go out": + * `Previous` onto page one sends none and is still a move. See + * {@link askedForPage}. */ const [failure, setFailure] = React.useState<{ title: string; description: string; - continuation: boolean; + paging: boolean; } | null>(null); const [busy, setBusy] = React.useState(true); // The Retry's OWN in-flight state. `busy` is the whole screen's, and the @@ -478,6 +524,21 @@ export function ProductsList({ const [cursor, setCursor] = React.useState | null>(() => seedCursor(initialFilter, initialCursor), ); + /** + * THE PAGES WALKED TO GET HERE — the client-side stack `Previous` pops. + * + * SEEDED FROM THE SAME ADDRESS THE CURSOR IS, and ungrounded when that address + * named a page: a link says WHICH page, never HOW MANY came before it, so this + * mount can go forward and come back without ever being entitled to print a + * page number. See {@link PageTrail}. + * + * IT MOVES ON THE CLICK, exactly as the cursor and the address do, and is not + * rewound by a refusal — a failed page withdraws the pager rather than + * pretending the merchant never asked. + */ + const [trail, setTrail] = React.useState( + () => initialTrail ?? seedTrail(initialCursor), + ); /** The address named a page that would not open, and this render is the first * page of its filters instead — the SEEDED path only. See the effect. */ const [cursorReset, setCursorReset] = React.useState(false); @@ -545,7 +606,14 @@ export function ProductsList({ // `continuationCursor`): one belonging to a filter that has since been // replaced is not sent, and this request is the new filter's first page. const from = continuationCursor(cursor, applied); - const continuation = from !== undefined; + // DID THE MERCHANT ASK FOR THIS PAGE? Not "is there a cursor on the wire" — + // `Previous` onto page one sends none and is still a move. See + // {@link askedForPage}. + const paging = askedForPage(cursor, applied); + // WHICH OF THE TWO ADVANCING CONTROLS ISSUED IT. Only `Load more` extends + // the rows on screen; a pager step and a deep link name a page that stands + // on its own. See `PendingCursor.extend`. + const extending = paging && cursor?.extend === true; setBusy(true); void fetchProducts(applied, from).then((result) => { if (cancelled) return; @@ -563,11 +631,11 @@ export function ProductsList({ * A failure therefore leaves the cursor exactly where it was, in state * and in the address, so a reload after recovery still restores the page. */ - setFailure({ title: result.title, description: result.description, continuation }); + setFailure({ title: result.title, description: result.description, paging }); // F2: THE ANSWER GOES WITH THE FAILURE, in the same transition — for a // FIRST page. A page behind one that succeeded takes only its own // cursor with it (F24); see `pageAfterFailure`. - setPage((current) => pageAfterFailure(current, continuation)); + setPage((current) => pageAfterFailure(current, paging)); return; } setFailure(null); @@ -599,9 +667,22 @@ export function ProductsList({ if (rejected) { skipRefetchAfterReset.current = true; setCursor(null); - onCursorChange?.(undefined); + // A CORRECTION, NEVER A JOURNEY: the entry the merchant is standing on + // is rewritten rather than buried under one they never asked for. The + // stack it records is page one's, which is what a reload of the + // corrected address would produce — mid-scan that deliberately differs + // from the in-memory stack, which still describes the rows on screen. + onCursorChange?.({ cursor: undefined, trail: FIRST_PAGE, kind: "correct" }); if (midScan) setPagingStopped(true); - else setCursorReset(true); + else { + setCursorReset(true); + // THE ROWS BELOW REALLY ARE PAGE ONE, so the stack has to say so — + // otherwise the position would keep the unknowable page the address + // asked for while the screen showed the first one. The mid-scan + // branch deliberately does NOT reset: those rows are still the pages + // the merchant gathered, and the pager is withdrawn there anyway. + setTrail(FIRST_PAGE); + } } if (midScan) { // THE ONE RESPONSE THIS SCREEN THROWS AWAY. Every row in it is real and @@ -614,6 +695,33 @@ export function ProductsList({ // F24: MERGE, NEVER ASSIGN. The functional form is required, not // stylistic: the rows it merges into are the ones in state at the moment // the response lands. + /* + * WHAT THE WIRE SAYS THIS RESPONSE IS — and it is the WIRE, not the + * operator's intent, that decides. + * + * A REQUEST THAT CARRIED NO CURSOR IS PAGE ONE, whoever asked for it. A + * filter apply, a first mount and a `Previous` off the bottom of the + * stack all send the same empty request and all come back with the same + * thing: the first page, under the current predicate, answered + * authoritatively. Calling the last of those a `replace` — which the + * first cut did, because the OPERATOR had asked for a page — was wrong in + * two directions at once. It captioned a render that IS the first page as + * `firstPage: false`, which takes the whole-collection empty copy away and + * puts the "on this page" hedge on a count the render could prove; and on + * Pricing & inventory it carried a latch forward over a response entitled + * to clear it, so a banner raised by a settings blip could never go away + * — while a blip happening ON that request could not raise one. + * + * THE FAILURE CLASSIFICATION IS A SEPARATE QUESTION and stays on `paging`. + * "Is this response page one" and "do the rows on screen survive this + * request failing" are genuinely different questions, and keeping them + * apart is what makes answering the first one straight off the wire safe. + * + * A REFUSED CURSOR IS PAGE ONE TOO: the plugin already performed the + * recovery, so these are the first page's rows however they were asked for. + */ + const arrival: PageArrival = + rejected || from === undefined ? "reset" : extending ? "extend" : "replace"; setPage((current) => nextPage( current, @@ -624,10 +732,7 @@ export function ProductsList({ stock: result.stock, vocabulary: result.vocabulary, }, - // A REFUSED CURSOR MAKES THIS A RESET whatever the request was: these - // are page one's rows, and merging them onto an accumulation would - // caption a first page as the middle of a scan. - continuation && !rejected, + arrival, ), ); }); @@ -736,7 +841,7 @@ export function ProductsList({ // A CONTINUATION FAILURE WITHDRAWS NOTHING (F24). The rows above it are the // answer to a request that succeeded, so they, the count line and the alert // that describes them all stand; only a FIRST-page failure takes them. - const answerVisible = failure === null || failure.continuation; + const answerVisible = failure === null || failure.paging; const degraded = visibleDegradation(page, !answerVisible); // F3: a cold failure has no vocabulary to build the two selects from, so the // panel goes with the answer rather than standing there empty. @@ -744,6 +849,117 @@ export function ProductsList({ // WHICH OF THE TWO PLACES THE FAILURE IS DRAWN IN — see `failureNotice`. const notice = failureNotice(failure); + /** + * THE PAGER, decided in `pagerView` and only drawn here. + * + * WITHDRAWN WHEREVER `Load more` IS. A failure has already replaced the offer + * to page with a Retry for the request that failed, and the paging-stopped + * state has just taken the page out of the ADDRESS — leaving `Previous` + * standing there would offer to step back relative to a position the screen + * disowned one line above. Both states leave the rows exactly where they are; + * it is only the paging that goes. + * + * THE `total` IS WHATEVER THIS RENDER IS ENTITLED TO STATE — the same value + * the count line reads, so a low-stock page whose predicate never ran (see + * `nextPage`) withholds the page count exactly as it withholds the count. + */ + const pager = pagerView({ + trail, + hasNext, + rows: products.length, + // THE COUNT LINE'S OWN FIGURE, not the payload's — `listOutcome` is the one + // place a `total` is validated and, on some scopes, withheld, and a page + // count derived from a number the caption refused would contradict it one + // line down. On this screen that is not hypothetical: a `filterUnavailable` + // page withholds its total by design. + ...(outcome.statedTotal !== undefined ? { total: outcome.statedTotal } : {}), + // HOW MANY PAGES ARE ON SCREEN AT ONCE. Above one the position states the + // window (`Pages 2–3 of 6`) rather than only where it ends. + span: page?.pages ?? 1, + // THE PAGE SIZE IS ON THE WIRE ALREADY — the plugin sends the keyset limit + // it pages by, so `M` costs no request. A service that omits it leaves the + // page count an em dash rather than a guess. + ...(vocabulary !== undefined ? { pageSize: vocabulary.pageLimit } : {}), + busy, + withdrawn: page === null || !answerVisible || failure !== null || pagingStopped, + }); + /** The same withdrawal, on the control that was already gated this way. */ + const loadMoreVisible = page?.nextCursor != null && failure === null && !pagingStopped; + + /** + * One page forward, from whichever control asked. Both push the same stack; + * they disagree only about whether the rows above stay. + * + * IT READS `trail` OUT OF THE CLOSURE, which is the exception to the functional + * `setPage` form a few lines up, and the difference is WHEN the value is + * needed. `setPage` runs when a RESPONSE lands, which may be several renders + * after the request went out, so it must see whatever is in state then. This + * runs inside the click, on the render the merchant is looking at, and the + * same value has to reach three places at once — the state, the history entry, + * and the cursor — so reading it once is what keeps the three in step. A + * functional update here would hand the entry a stack the state had not + * committed to. + * + * THE BUSY GUARD IS STILL LOAD-BEARING, and {@link pushedPage}'s idempotence + * does not replace it: repeating a cursor cannot deepen the STACK, but two + * presses resolved before the effect runs would still push two history + * ENTRIES for one page, and a duplicate entry is not something this tier can + * take back. + */ + const goForward = (extend: boolean) => { + const value = page?.nextCursor; + if (value == null) return; + setCursor({ filter: applied, value, ...(extend ? { extend: true } : {}) }); + const moved = pushedPage(trail, value); + setTrail(moved); + setCursorReset(false); + // BUSY IS THE CLICK'S, NOT THE EFFECT'S — the same rule `apply` follows. The + // effect that issues the request runs after this commit, so without this + // there is one render in which the position has already moved and both pager + // controls are still live: a second press would push the SAME cursor again + // and leave the stack one deeper than the pages actually walked. + setBusy(true); + // The page goes in the address, and the screen is the only writer — this + // states what happened, it does not navigate. `navigate`, because the + // merchant went somewhere: the entry is pushed, and it carries the stack + // that produced it so a later Back lands here still knowing where it is. + onCursorChange?.({ cursor: value, trail: moved, kind: "navigate" }); + }; + + /** + * One page back, by REPLAYING the cursor the stack popped. + * + * IT RE-REQUESTS RATHER THAN RESTORING. The stack holds cursors, not pages, and + * that is the choice rather than an implementation detail: a re-request under a + * token the service already issued is exact and answers with the catalog as it + * stands NOW — which on this screen is the whole point, since the column an + * operator came back to check is stock. Replaying rows kept in memory would + * show a page that may be minutes stale, would disagree with a reload of the + * very same address, and would grow without bound down a long scan. + * + * POPPING THE LAST ENTRY IS PAGE ONE, cursor and all: the request goes out + * without a token and the address is corrected through the one path this list + * announces its page on. + */ + const goBack = () => { + const { trail: rest, cursor: target } = poppedPage(trail); + setTrail(rest); + // A CURSOR OBJECT EVEN WHEN THE PAGE IS ONE. `null` would mean "no page was + // asked for" and would make a failure here clear the rows — see + // {@link askedForPage}. Page one is asked for by sending no token, which is + // a `value` of `undefined`, not by having no request. + setCursor({ filter: applied, value: target }); + setCursorReset(false); + // See `goForward`: the controls go unavailable on the click rather than on + // the effect, so the commit in between cannot take a second press. + setBusy(true); + // NAVIGATE, NOT CORRECT — the correction that shares this shape is the + // refused-cursor recovery, which REPLACES the entry. A merchant stepping + // back deliberately went somewhere, and overwriting the entry they stepped + // from would delete the page they just left from their own history. + onCursorChange?.({ cursor: target, trail: rest, kind: "navigate" }); + }; + // THE FAILURE IS NOT CLEARED ON THE CLICK. Clearing it here rather than on the // response would flash the stale answer back for the length of the request. const retryAction = { @@ -760,6 +976,13 @@ export function ProductsList({ setApplied(next); setDraft(next); setCursor(null); + // THE STACK RESETS WITH THE PREDICATE, and this is not tidiness. A cursor + // is only meaningful against the filter it was issued under, so a stack that + // survived an apply would hand `Previous` a token from the set the merchant + // just left — a page of the old predicate, or (once the service notices the + // disagreement) a refusal and a bounce back to page one. Page one of the new + // filter has nothing behind it, and the pager must say so. + setTrail(FIRST_PAGE); // THE RESET NOTICE IS ABOUT THE ARRIVAL, so it goes the moment the merchant // asks for something themselves. setCursorReset(false); @@ -1141,10 +1364,11 @@ export function ProductsList({ )} {/* - THE CONTINUATION FAILURE RENDERS WHERE `Load more` WAS, and replaces - it: the button and the notice would otherwise offer the same request - twice, and the one that failed is the one the merchant just pressed. - The cursor is NOT destroyed to achieve that — see `pageAfterFailure`. + A FAILED PAGE MOVE RENDERS WHERE THE PAGING BAR WAS, and replaces the + whole bar: its controls and this notice would otherwise offer the same + request twice, and the one that failed is the one the merchant just + pressed. It replaces `Previous`/`Next` as much as `Load more`. The + cursor is NOT destroyed to achieve that — see `pageAfterFailure`. */} {notice !== null && notice.inline && (
@@ -1159,8 +1383,8 @@ export function ProductsList({ )} {/* - PAGING STOPPED MID-SCAN — rendered where `Load more` was, because it is - what replaces that control. NO FOCUS MOVE, unlike the seeded-link + PAGING STOPPED MID-SCAN — rendered where the paging bar was, because it + is what replaces every control in it. NO FOCUS MOVE, unlike the seeded-link notice: the operator is mid-interaction with their hands on the page, and taking focus off what they were doing to announce a control that simply is not there any more would be the more disruptive answer to the @@ -1177,26 +1401,64 @@ export function ProductsList({
)} - {page?.nextCursor != null && failure === null && !pagingStopped && ( -
- + {/* + THE TWO WAYS FORWARD, IN ONE BAR — and they are two acts, not two + spellings of one. `Previous`/`Next` MOVE a one-page window and are how a + merchant navigates a long catalog; `Load more` EXTENDS the window and is + how they build a low-stock scan to read in one go. Both advance the same + position, so the page number below counts either — what differs is + whether the rows above stay. + */} + {(pager.visible || loadMoreVisible) && ( +
+ {pager.visible && ( + + )} + {loadMoreVisible && ( + + )}
)}
diff --git a/packages/admin-react/src/products/products-screen.tsx b/packages/admin-react/src/products/products-screen.tsx index 9978e98..fd22ce2 100644 --- a/packages/admin-react/src/products/products-screen.tsx +++ b/packages/admin-react/src/products/products-screen.tsx @@ -28,7 +28,17 @@ import type { ProductsFilter } from "../console-api.js"; import { PRODUCT_KIND_LABELS } from "@otta-sh/admin-presentation"; import * as React from "react"; -import { CURSOR_PARAM, cursorQuery, readCursor } from "../accumulate.js"; +import { + CURSOR_PARAM, + FIRST_PAGE, + cursorQuery, + entryState, + readCursor, + readTrailState, + seedTrail, + type PageChange, + type PageTrail, +} from "../accumulate.js"; import { ConsoleStyles } from "../ui.js"; import { ProductDetail } from "./product-detail.js"; import { ProductsList } from "./products-list.js"; @@ -158,6 +168,12 @@ function currentSearch(): string { return typeof window === "undefined" ? "" : window.location.search; } +/** The entry's own state, which is where the pager's stack lives — see + * {@link PAGE_STATE_KEY}. */ +function currentState(): unknown { + return typeof window === "undefined" ? null : window.history.state; +} + /** * Swap the whole query, keeping the entry. * @@ -168,11 +184,15 @@ function currentSearch(): string { * relies on. Returns the resulting address, which the caller keeps so it can * restore it. */ -function replaceQuery(query: string): string { +function replaceQuery(query: string, trail?: PageTrail): string { if (typeof window === "undefined") return ""; const url = new URL(window.location.href); url.search = query; - window.history.replaceState(window.history.state, "", url); + // THE ENTRY'S OWN RECORD OF THE WALK rides alongside the address — through + // {@link entryState}, which merges rather than clobbers. Tabs and the + // unsaved-work guard write through here too, and a tab change must not + // silently drop what a later Back would read. + window.history.replaceState(entryState(window.history.state, {}, trail), "", url); return url.href; } @@ -186,11 +206,16 @@ function replaceQuery(query: string): string { * It does NOT touch `detailHref`: this fires only while the list is on screen, * where there is no record to restore. */ -function pushQuery(query: string): void { +function pushQuery(query: string, trail: PageTrail): void { if (typeof window === "undefined") return; const url = new URL(window.location.href); url.search = query; - window.history.pushState({ ottaProduct: null }, "", url); + // THE STACK GOES ON THE ENTRY, which is what lets a traversal land on page + // four still knowing it is page four. The ADDRESS carries one cursor, because + // that is what makes a link shareable; a history entry is this browser's + // private note about somewhere this merchant already stood, and can hold what + // a link cannot. + window.history.pushState(entryState(window.history.state, { ottaProduct: null }, trail), "", url); } function readSelectedProduct(): string | null { @@ -199,11 +224,19 @@ function readSelectedProduct(): string | null { return value !== null && value.length > 0 ? value : null; } -function pushSelectedProduct(productId: string): string { +function pushSelectedProduct(productId: string, trail: PageTrail): string { if (typeof window === "undefined") return ""; const url = new URL(window.location.href); url.searchParams.set(PRODUCT_PARAM, productId); - window.history.pushState({ ottaProduct: productId }, "", url); + // THE RECORD'S ENTRY CARRIES THE LIST'S PAGE TOO — a drill-in from page four + // is still page four, and Back (or a reload of the record's own address + // followed by `Back to pricing & inventory`) has to return the merchant to a + // list that knows it. + window.history.pushState( + entryState(window.history.state, { ottaProduct: productId }, trail), + "", + url, + ); return url.href; } @@ -232,7 +265,9 @@ function popSelectedProduct(pushed: boolean): void { // address afterwards: a `tab` left behind here would sit on a list URL that // has no tabs and then seed the NEXT record the merchant opened. url.searchParams.delete(TAB_PARAM); - window.history.replaceState({ ottaProduct: null }, "", url); + // MERGED, NEVER CLOBBERED: this writer means "no record is open" and nothing + // else, so the entry's page stack is not its to discard. + window.history.replaceState(entryState(window.history.state, { ottaProduct: null }), "", url); } export function ProductsScreen(): React.ReactElement { @@ -249,6 +284,13 @@ export function ProductsScreen(): React.ReactElement { * it has paged to once mounted, and this is what it starts from on a first * mount and on the remount a traversal triggers. */ const [cursor, setCursor] = React.useState(() => readCursor(currentSearch())); + /** THE WALK THIS ENTRY RECORDED, read from `history.state` so a traversal + * keeps the pager's position; absent — a pasted link, a fresh tab, an entry + * the host pushed — it falls back to seeding from the address, which is the + * deep-link behaviour. Only ever a SEED, exactly like the cursor. */ + const [trail, setTrail] = React.useState( + () => readTrailState(currentState()) ?? seedTrail(readCursor(currentSearch())), + ); const [tab, setTab] = React.useState(() => readProductTab(currentSearch())); /** Bumped on every `popstate`, and used as the child's `key`: a traversal is * the one moment the URL knows something the mounted child does not, so the @@ -292,15 +334,20 @@ export function ProductsScreen(): React.ReactElement { */ const detailHref = React.useRef(null); - /** PAGING PUSHES; RETURNING TO PAGE ONE REPLACES — the same rule the Orders - * screen states at length. `useCallback` is required rather than tidy: the - * list depends on this function in its fetch effect, so a fresh arrow per - * render would re-fetch on every render. It closes over nothing that changes. */ - const onCursorChange = React.useCallback((next: string | undefined) => { - setCursor(next); - const query = cursorQuery(currentSearch(), next); - if (next === undefined) replaceQuery(query); - else pushQuery(query); + /** NAVIGATION PUSHES; A CORRECTION REPLACES, and the LIST says which — the + * same rule the Orders screen states at length, including why the intent + * cannot be inferred from "no cursor" (page one is reached both by an + * operator stepping back and by the recovery from a page that would not + * open). Every write carries the stack, so a traversal lands knowing where it + * is. `useCallback` is required rather than tidy: the list depends on this + * function in its fetch effect, so a fresh arrow per render would re-fetch on + * every render. It closes over nothing that changes. */ + const onCursorChange = React.useCallback((change: PageChange) => { + setCursor(change.cursor); + setTrail(change.trail); + const query = cursorQuery(currentSearch(), change.cursor); + if (change.kind === "correct") replaceQuery(query, change.trail); + else pushQuery(query, change.trail); }, []); React.useEffect(() => { @@ -394,7 +441,10 @@ export function ProductsScreen(): React.ReactElement { */ const href = detailHref.current; if (unsaved.current && was !== null && href !== null && next !== was) { - window.history.pushState({ ottaProduct: was }, "", href); + // DEPTH-NEUTRAL AND STATE-NEUTRAL: this re-push puts the record's own + // entry back exactly as it was, so it merges rather than composing a + // fresh object that would drop the list's page from it. + window.history.pushState(entryState(window.history.state, { ottaProduct: was }), "", href); pushed.current = true; setLeavePrompt((n) => n + 1); return; @@ -406,6 +456,10 @@ export function ProductsScreen(): React.ReactElement { // second mechanism: one traversal re-derives the whole address, and the // `key={restore}` bump rebuilds the list from all of it at once. setCursor(readCursor(currentSearch())); + // AND THE STACK THE ENTRY CARRIED — without it a Back onto a page the + // merchant had walked to came back as if it had been pasted in: position + // dashed, `Previous` dimmed, two presses into a scan. + setTrail(readTrailState(window.history.state) ?? seedTrail(readCursor(currentSearch()))); setTab(readProductTab(currentSearch())); setRestore((n) => n + 1); }; @@ -421,15 +475,19 @@ export function ProductsScreen(): React.ReactElement { key={restore} initialFilter={filter} initialCursor={cursor} + initialTrail={trail} onFilterChange={(next) => { // The filter query drops the cursor with the filter it belonged to - // (see `productsFilterQuery`), so this one write says both things. + // (see `productsFilterQuery`), so this one write says both things — + // and the entry's stack goes with them, because a stack from the + // previous predicate is exactly what `Previous` must never pop. setCursor(undefined); - replaceQuery(productsFilterQuery(currentSearch(), next)); + setTrail(FIRST_PAGE); + replaceQuery(productsFilterQuery(currentSearch(), next), FIRST_PAGE); }} onCursorChange={onCursorChange} onOpen={(productId) => { - detailHref.current = pushSelectedProduct(productId); + detailHref.current = pushSelectedProduct(productId, trail); pushed.current = true; setSelected(productId); }} diff --git a/packages/admin-react/src/ui.tsx b/packages/admin-react/src/ui.tsx index 20e3679..6806b2c 100644 --- a/packages/admin-react/src/ui.tsx +++ b/packages/admin-react/src/ui.tsx @@ -33,6 +33,39 @@ export const OK_ACCENT = "#2f855a"; export const FAIL_ACCENT = "#c53030"; export const WARN_ACCENT = "#b7791f"; export const MUTED = "rgba(128, 128, 128, 0.6)"; +/** + * THE LOOK OF A CONTROL THAT IS PRESENT BUT CANNOT BE USED. + * + * TWO OVERCORRECTIONS, AND THIS IS THE MIDDLE. The first cut dropped the whole + * button to `opacity: 0.45`, which took the 13px LABEL down with the border and + * left a word a low-vision operator had to work to read. The second went the + * other way — a near-invisible border and a token opacity — and made unavailable + * and live nearly indistinguishable, which is worse: a control that looks + * pressable and does nothing. + * + * SO THE STATE IS CARRIED BY THREE DECLARATIONS, and the point is WHICH of them + * loses strength. A flat fill (the surface reads as inert rather than raised), a + * border visibly lighter than {@link HAIRLINE} but still THERE, and a label + * mixed down to 62% of the theme's own foreground. + * + * THE MIX IS AN ALPHA — that is exactly what `color-mix(…, transparent)` is, and + * claiming otherwise would be dressing up the same technique in better words. + * What makes it different from the `opacity` it replaces is WHERE it lands: + * `opacity` fades the whole element, so the border and the fill that carry the + * state fade at precisely the rate the label does, and the only way to keep the + * state visible is to keep the label legible or vice versa. Here the fill and the + * border stay at full strength and only the word is muted, so the two jobs stop + * competing. 62% clears the contrast floor at this size while reading + * unmistakably as "off"; it is stated against `currentColor` so it follows the + * theme rather than pinning a grey that is legible in one of them. + * + * A whole `border` rather than a `border-color`: `buttonStyle` states the + * shorthand, and React warns (correctly) that mixing the two on one element + * makes removals order-dependent. + */ +export const UNAVAILABLE_BORDER = "1px solid rgba(128, 128, 128, 0.22)"; +export const UNAVAILABLE_FILL = "rgba(128, 128, 128, 0.10)"; +export const UNAVAILABLE_INK = "color-mix(in srgb, currentColor 62%, transparent)"; /** * The attribute a row carries its record id in. @@ -135,6 +168,23 @@ export const CONSOLE_STYLES = ` .otta-num { font-variant-numeric: tabular-nums; } .otta-btn { cursor: pointer; } .otta-btn:disabled { cursor: not-allowed; } +/* A CONTROL DIMMED WITHOUT LEAVING THE TAB ORDER — see PagerButton. The rule + above cannot match it: aria-disabled is a STATE, not the disabled property, + and the whole point of using it is that the element stays focusable and + clickable at the DOM level while refusing its own click. (No backticks in + this sheet: it is a template literal, and one would end it.) */ +.otta-btn[aria-disabled="true"] { cursor: not-allowed; } +/* Present to assistive technology, absent from the page. The same recipe the + table caption uses inline; it is a class here because it is applied to + elements that are not tables and would otherwise be a fourth copy. */ +.otta-sr-only { + position: absolute; + inline-size: 1px; + block-size: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} .otta-summary { cursor: pointer; } .otta-row[${ROW_ID_ATTRIBUTE}] { cursor: pointer; @@ -293,6 +343,84 @@ export function Button({ ); } +/** + * ONE PAGER CONTROL — `Previous` or `Next`, on either list. + * + * IT DECIDES NOTHING. Which of the two states it is in, and what it says about + * being unavailable, are `pagerView`'s answer (`accumulate.ts`, which reads its + * words from `@otta-sh/admin-presentation`). This is the markup, and it is HERE + * rather than in a list because both lists rendered it verbatim: two copies of a + * control whose accessibility is the interesting part is two copies that drift. + * + * `aria-disabled` RATHER THAN `disabled`, which is the whole reason this is not + * `Button`. A `disabled` element leaves the tab order, and that is exactly what + * must not happen at the moment `Next` is pressed onto the LAST page: the + * control the operator's focus is sitting on would stop being focusable under + * their hands and the browser would drop focus to ``, halfway down a long + * list, with no ring to find. So it keeps its tab stop and its focus ring, + * announces itself as unavailable, and refuses its own click. + * + * THE REASON IS A VISUALLY-HIDDEN SENTENCE, referenced by `aria-describedby`, + * and `title` is a bonus rather than the mechanism. The earlier version had this + * exactly backwards — it claimed `title` was what a keyboard user could reach, + * which is the one thing `title` is NOT: a tooltip is a POINTER affordance, + * shown on hover, and keyboard exposure of it is inconsistent across browsers + * and screen readers. A described-by node is read out with the control's name + * every time, by every screen reader, whether the operator arrived by pointer, + * by tab, or by a rotor listing of the page's buttons. + * + * DIMMED WITHOUT GOING ILLEGIBLE, AND WITHOUT GOING INVISIBLE. See + * {@link UNAVAILABLE_BORDER} for what the state is drawn with and why it is + * three declarations rather than an opacity. + */ +export function PagerButton({ + control, + testId, + onClick, +}: { + control: { readonly label: string; readonly unavailable: boolean; readonly title?: string }; + testId: string; + onClick: () => void; +}): React.ReactElement { + // `useId` rather than a name derived from `testId`: two lists could mount at + // once under a host that renders both, and a duplicated id would point every + // description at whichever one the document found first. + const describedBy = `${React.useId()}-why`; + const reason = control.title; + return ( + + + {reason !== undefined && ( + + {reason} + + )} + + ); +} + /** * §1.3's React tier, and the affordance Block Kit could not have at all: the row * shows a short prefix, this copies the WHOLE id. diff --git a/packages/admin-react/test/load-more-dom.test.tsx b/packages/admin-react/test/load-more-dom.test.tsx index 5081c61..3ae856e 100644 --- a/packages/admin-react/test/load-more-dom.test.tsx +++ b/packages/admin-react/test/load-more-dom.test.tsx @@ -14,7 +14,7 @@ * EVERY RESPONSE IS SERVED HERE, successes included, so a failure is a * transition this file chooses rather than an environment it has to arrange. */ -import { PRODUCTS_LOAD_MORE_FAILED_TITLE } from "@otta-sh/admin-presentation"; +import { PRODUCTS_PAGE_FAILED_TITLE } from "@otta-sh/admin-presentation"; import * as React from "react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { fire, mount, type Mounted } from "./dom.js"; @@ -556,7 +556,7 @@ test("a continuation failure on the products list keeps the rows, the cursor's c // INLINE, WHERE THE CONTROL WAS, exactly as on Orders — and under a title the // rows on screen do not disprove, rather than the service's whole-collection // refusal carried to the top of the screen above them. - expect(text(view, "products-load-more-failure")).toContain(PRODUCTS_LOAD_MORE_FAILED_TITLE); + expect(text(view, "products-load-more-failure")).toContain(PRODUCTS_PAGE_FAILED_TITLE); expect(text(view, "products-load-more-failure")).not.toContain("Products could not be reached"); expect(absent(view, "products-failure")).toBe(true); // The offer that just failed is not made twice. diff --git a/packages/admin-react/test/load-more.test.ts b/packages/admin-react/test/load-more.test.ts index 135c202..2e7dbf5 100644 --- a/packages/admin-react/test/load-more.test.ts +++ b/packages/admin-react/test/load-more.test.ts @@ -12,7 +12,7 @@ import { ACCUMULATED_SUFFIX, PRODUCTS_EMPTY, - PRODUCTS_LOAD_MORE_FAILED_TITLE, + PRODUCTS_PAGE_FAILED_TITLE, PRODUCTS_LOW_STOCK_NOUN, PRODUCTS_LOW_STOCK_NO_MATCH, PRODUCTS_NOUN, @@ -108,11 +108,11 @@ const ordersPage = (ids: readonly string[], nextCursor: string | null, total?: n describe("which requests EXTEND the list and which RESET it", () => { test("a continuation extends, and the count follows the rows", () => { - const first = ordersNextPage(null, ordersPage(["a", "b"], "cursor-2", 4), false); + const first = ordersNextPage(null, ordersPage(["a", "b"], "cursor-2", 4), "reset"); expect(first.orders).toHaveLength(2); expect(first.pages).toBe(1); - const second = ordersNextPage(first, ordersPage(["c", "d"], null, 4), true); + const second = ordersNextPage(first, ordersPage(["c", "d"], null, 4), "extend"); expect(second.orders.map((o) => o.id)).toEqual(["a", "b", "c", "d"]); // The cursor and the total take the NEW page's values... expect(second.nextCursor).toBeNull(); @@ -124,8 +124,8 @@ describe("which requests EXTEND the list and which RESET it", () => { }); test("a NON-continuation resets — this is the filter change, and the deep link", () => { - const first = ordersNextPage(null, ordersPage(["a", "b"], "cursor-2"), false); - const refiltered = ordersNextPage(first, ordersPage(["x"], null), false); + const first = ordersNextPage(null, ordersPage(["a", "b"], "cursor-2"), "reset"); + const refiltered = ordersNextPage(first, ordersPage(["x"], null), "reset"); // The previous filter's rows are not the new filter's answer. Extending // here is how a filtered list shows rows that do not match it. expect(refiltered.orders.map((o) => o.id)).toEqual(["x"]); @@ -142,11 +142,11 @@ describe("which requests EXTEND the list and which RESET it", () => { stock, vocabulary: { statuses: [], kinds: [], any: "any", pageLimit: 25 } as never, }); - const first = productsNextPage(null, productsPage(["p1", "p2"], "cursor-2"), false); - const second = productsNextPage(first, productsPage(["p2", "p3"], null), true); + const first = productsNextPage(null, productsPage(["p1", "p2"], "cursor-2"), "reset"); + const second = productsNextPage(first, productsPage(["p2", "p3"], null), "extend"); expect(second.products.map((p) => p.productId)).toEqual(["p1", "p2", "p3"]); expect(second.pages).toBe(2); - expect(productsNextPage(second, productsPage(["p9"], null), false).products).toHaveLength(1); + expect(productsNextPage(second, productsPage(["p9"], null), "reset").products).toHaveLength(1); }); }); @@ -160,7 +160,7 @@ function productsLoaded() { stock: { threshold: 3, unreadable: false, filterUnavailable: false }, vocabulary: { statuses: [], kinds: [], any: "any", pageLimit: 25 } as never, }, - false, + "reset", ); } @@ -186,9 +186,9 @@ describe("a total the service never calculated stays ABSENT through a merge", () // applied to. Coercing that to zero inside the merge is the one place this // change could invent a number, and it would then be handed to the count // line as fact. - const firstOrders = ordersNextPage(null, ordersPage(["a"], "cursor-2"), false); + const firstOrders = ordersNextPage(null, ordersPage(["a"], "cursor-2"), "reset"); expect(firstOrders.total).toBeUndefined(); - expect(ordersNextPage(firstOrders, ordersPage(["b"], null), true).total).toBeUndefined(); + expect(ordersNextPage(firstOrders, ordersPage(["b"], null), "extend").total).toBeUndefined(); const stock = { threshold: 3, unreadable: false, filterUnavailable: false }; const productsPage = (ids: readonly string[], nextCursor: string | null) => ({ @@ -198,9 +198,11 @@ describe("a total the service never calculated stays ABSENT through a merge", () stock, vocabulary: { statuses: [], kinds: [], any: "any", pageLimit: 25 } as never, }); - const firstProducts = productsNextPage(null, productsPage(["p1"], "cursor-2"), false); + const firstProducts = productsNextPage(null, productsPage(["p1"], "cursor-2"), "reset"); expect(firstProducts.total).toBeUndefined(); - expect(productsNextPage(firstProducts, productsPage(["p2"], null), true).total).toBeUndefined(); + expect( + productsNextPage(firstProducts, productsPage(["p2"], null), "extend").total, + ).toBeUndefined(); }); }); @@ -226,9 +228,9 @@ describe("`filterUnavailable` LATCHES across a scan, because it describes the ro // (`resolveStockContext`'s decision 3). Taking the newest response at face // value would drop the banner and caption every product in the catalog as // "N low-stock products" at the click of `Load more`. - const first = productsNextPage(null, stockPage(["p1"], "cursor-2", true, undefined), false); + const first = productsNextPage(null, stockPage(["p1"], "cursor-2", true, undefined), "reset"); expect(first.stock.filterUnavailable).toBe(true); - const second = productsNextPage(first, stockPage(["p2"], null, false, 137), true); + const second = productsNextPage(first, stockPage(["p2"], null, false, 137), "extend"); expect(second.stock.filterUnavailable).toBe(true); // AND THE TOTAL GOES WITH IT. Not because the rows are a mixture — they // were all fetched WITHOUT the predicate — but because 137 is the count of @@ -241,8 +243,8 @@ describe("`filterUnavailable` LATCHES across a scan, because it describes the ro test("a scan that was filtered throughout keeps its total and raises nothing", () => { // THE CONVERSE, so the latch cannot be satisfied by always answering true. - const first = productsNextPage(null, stockPage(["p1"], "cursor-2", false, 137), false); - const second = productsNextPage(first, stockPage(["p2"], null, false, 137), true); + const first = productsNextPage(null, stockPage(["p1"], "cursor-2", false, 137), "reset"); + const second = productsNextPage(first, stockPage(["p2"], null, false, 137), "extend"); expect(second.stock.filterUnavailable).toBe(false); expect(second.total).toBe(137); }); @@ -250,8 +252,8 @@ describe("`filterUnavailable` LATCHES across a scan, because it describes the ro test("a RESET drops the latch — a re-applied filter is a new question", () => { // `apply()` nulls the cursor, so the next response arrives as a reset and // the previous scan's degradation must not outlive it. - const first = productsNextPage(null, stockPage(["p1"], "cursor-2", true, undefined), false); - const reset = productsNextPage(first, stockPage(["p9"], null, false, 4), false); + const first = productsNextPage(null, stockPage(["p1"], "cursor-2", true, undefined), "reset"); + const reset = productsNextPage(first, stockPage(["p9"], null, false, 4), "reset"); expect(reset.stock.filterUnavailable).toBe(false); expect(reset.total).toBe(4); }); @@ -298,7 +300,7 @@ describe("a page that fails BEHIND one that succeeded", () => { }); test("the count keeps its qualifier while a page is still out there", () => { - const after = pageAfterFailure(productsNextPage(loaded, PAGE_TWO, true), true); + const after = pageAfterFailure(productsNextPage(loaded, PAGE_TWO, "extend"), true); expect( listOutcome({ count: after?.products.length ?? 0, @@ -339,11 +341,11 @@ describe("where a failure is drawn, and what it may claim", () => { const notice = failureNotice({ title: "Products could not be reached", description: "Try again.", - continuation: true, + paging: true, }); // The service's whole-collection refusal is dropped: the rows still on // screen are the answer to a request that worked. - expect(notice?.title).toBe(PRODUCTS_LOAD_MORE_FAILED_TITLE); + expect(notice?.title).toBe(PRODUCTS_PAGE_FAILED_TITLE); expect(notice?.inline).toBe(true); }); @@ -351,7 +353,7 @@ describe("where a failure is drawn, and what it may claim", () => { const notice = failureNotice({ title: "Products could not be reached", description: "Try again.", - continuation: false, + paging: false, }); expect(notice?.title).toBe("Products could not be reached"); expect(notice?.inline).toBe(false); diff --git a/packages/admin-react/test/orders-console.test.tsx b/packages/admin-react/test/orders-console.test.tsx index 9a6a071..19251c9 100644 --- a/packages/admin-react/test/orders-console.test.tsx +++ b/packages/admin-react/test/orders-console.test.tsx @@ -47,7 +47,7 @@ const { RefundsPanel, checkRefundInput, refundPanelMode } = const { BANNER_BUDGET, FULLY_REFUNDED_NOTE, - ORDERS_LOAD_MORE_FAILED_TITLE, + ORDERS_PAGE_FAILED_TITLE, ORDERS_STALE_CLEARED_NOTE, REFUNDS_GROUP_EMPTY_LABEL, REFUND_AMOUNT_INVALID, @@ -123,7 +123,7 @@ describe("a failed load stops showing the previous answer (F1)", () => { }; test("COLD — nothing ever loaded: the error card alone", () => { - const card = ordersFailureCard({ ...SERVED, continuation: false }, false); + const card = ordersFailureCard({ ...SERVED, paging: false }, false); expect(card.kind).toBe("cold"); // No rows, no count line, no Load more... expect(card.answerVisible).toBe(false); @@ -136,7 +136,7 @@ describe("a failed load stops showing the previous answer (F1)", () => { }); test("STALE — a first page failed under rows: the answer goes, the filters stay", () => { - const card = ordersFailureCard({ ...SERVED, continuation: false }, true); + const card = ordersFailureCard({ ...SERVED, paging: false }, true); expect(card.kind).toBe("stale"); expect(card.answerVisible).toBe(false); // The operator's typed filters are INPUT, not answer. @@ -151,12 +151,12 @@ describe("a failed load stops showing the previous answer (F1)", () => { }); test("PARTIAL — page two failed: every row and the count stand", () => { - const card = ordersFailureCard({ ...SERVED, continuation: true }, true); + const card = ordersFailureCard({ ...SERVED, paging: true }, true); expect(card.kind).toBe("partial"); expect(card.answerVisible).toBe(true); // The whole-collection title is DROPPED: the rows above disprove it. What // failed is the next page, and that is the whole of the claim. - expect(card.title).toBe(ORDERS_LOAD_MORE_FAILED_TITLE); + expect(card.title).toBe(ORDERS_PAGE_FAILED_TITLE); expect(card.title).not.toBe(SERVED.title); expect(card.description).toBe(SERVED.description); // Where `Load more` was, not above the rows it did not invalidate. @@ -168,8 +168,8 @@ describe("a failed load stops showing the previous answer (F1)", () => { // By the time the card is read the rows are already gone, so counting them // would report every stale failure as a cold one and take the filter bar // with it. - expect(ordersFailureCard({ ...SERVED, continuation: false }, true).kind).toBe("stale"); - expect(ordersFailureCard({ ...SERVED, continuation: false }, false).kind).toBe("cold"); + expect(ordersFailureCard({ ...SERVED, paging: false }, true).kind).toBe("stale"); + expect(ordersFailureCard({ ...SERVED, paging: false }, false).kind).toBe("cold"); }); test("clearing drops the answer and keeps the vocabulary the filters are built from", () => { @@ -213,7 +213,7 @@ describe("a failed load stops showing the previous answer (F1)", () => { // It is the COLD FAILURE that takes the bar, and only it (F3) — the Period // menu it would draw has no options. const cold = ordersChrome({ - failure: { ...SERVED, continuation: false }, + failure: { ...SERVED, paging: false }, everLoaded: false, retrying: false, }); @@ -222,7 +222,7 @@ describe("a failed load stops showing the previous answer (F1)", () => { // A stale failure keeps the bar; the operator's typed filters are input. const stale = ordersChrome({ - failure: { ...SERVED, continuation: false }, + failure: { ...SERVED, paging: false }, everLoaded: true, retrying: false, }); @@ -235,7 +235,7 @@ describe("a failed load stops showing the previous answer (F1)", () => { // cannot reach this decision. The filter bar stays interactive in the stale // and partial states, so an "Apply filters" the operator pressed must not // make the untouched Retry beside it read "Retrying…". - const failure = { ...SERVED, continuation: false }; + const failure = { ...SERVED, paging: false }; const idle = ordersChrome({ failure, everLoaded: true, retrying: false }); expect(idle.retry.label).toBe(RETRY_LABEL); expect(idle.retry.disabled).toBe(false); @@ -252,7 +252,7 @@ describe("a failed load stops showing the previous answer (F1)", () => { // unmounted under the operator's focus. expect( ordersChrome({ - failure: { ...SERVED, continuation: true }, + failure: { ...SERVED, paging: true }, everLoaded: true, retrying: false, }).retry.autoFocus, diff --git a/packages/admin-react/test/pager-dom.test.tsx b/packages/admin-react/test/pager-dom.test.tsx new file mode 100644 index 0000000..7c5cdcf --- /dev/null +++ b/packages/admin-react/test/pager-dom.test.tsx @@ -0,0 +1,1316 @@ +/** + * @vitest-environment happy-dom + * + * PREVIOUS, NEXT, AND `Page N of M` — the pager, in the two halves it is made + * of. + * + * THE ALGEBRA FIRST. Where the operator is in a keyset scan is a value: a + * CLIENT-SIDE STACK of the cursors they have paged through, pushed on the way + * forward and popped on the way back. Nothing on the wire changes, and no + * reverse keyset query exists — the exactness comes from replaying a cursor the + * service already issued. That value has its own rules (what a deep link seeds + * it with, when a pop lands on page one, when the position is simply not + * knowable) and they are pinned as pure functions, because a bug in them is a + * wrong page number rather than a wrong pixel. + * + * THEN THE WIRING, which only a document can settle: that `Next` REPLACES the + * rows rather than accumulating them, that `Previous` RE-REQUESTS the popped + * cursor rather than restoring rows from memory (the stack holds cursors, not + * pages — so what comes back is the store as it stands now, and one round trip + * is what buys that), that the address follows both, and that a filter apply + * resets the stack so `Previous` can never walk into another predicate's pages. + * + * EVERY RESPONSE IS SERVED HERE, `total` included, so `Page N of M` is proven + * against a service that reports the exact count and against one that does not — + * absent is an em dash, never "of 1". + */ +import * as React from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + FIRST_PAGE, + PAGE_STATE_KEY, + entryState, + hasPreviousPage, + pageNumber, + pagerView, + poppedPage, + pushedPage, + readTrailState, + seedTrail, + trailState, +} from "../src/accumulate.js"; +import { fire, mount, type Mounted } from "./dom.js"; + +const apiFetch = vi.fn<(input: string, init?: RequestInit) => Promise>(); + +vi.mock("emdash/plugin-utils", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, apiFetch }; +}); + +const { OrdersScreen } = await import("../src/orders/orders-screen.js"); +const { ProductsScreen } = await import("../src/products/products-screen.js"); +const { + NEXT_AT_END_TITLE, + NEXT_RELEASES_SCAN_TITLE, + PREVIOUS_AT_START_TITLE, + PREVIOUS_UNWALKED_TITLE, +} = await import("@otta-sh/admin-presentation"); + +// ── the stack, as a value ──────────────────────────────────────────────────── + +describe("the cursor stack — where the operator is, as a value", () => { + test("a fresh list is page one, with nothing behind it", () => { + expect(pageNumber(FIRST_PAGE)).toBe(1); + expect(hasPreviousPage(FIRST_PAGE)).toBe(false); + }); + + test("each page walked forward is one push, and the number follows", () => { + const two = pushedPage(FIRST_PAGE, "c2"); + const three = pushedPage(two, "c3"); + expect(pageNumber(two)).toBe(2); + expect(pageNumber(three)).toBe(3); + expect(hasPreviousPage(three)).toBe(true); + }); + + test("a pop names the cursor of the page it returns to", () => { + // THE WHOLE MECHANISM: the stack holds the cursors the service already + // issued, so going back is replaying one of them. No reverse keyset query + // exists, and none is needed. + const three = pushedPage(pushedPage(FIRST_PAGE, "c2"), "c3"); + const back = poppedPage(three); + expect(back.cursor).toBe("c2"); + expect(pageNumber(back.trail)).toBe(2); + }); + + test("popping the LAST cursor lands page one — no cursor at all", () => { + const back = poppedPage(pushedPage(FIRST_PAGE, "c2")); + expect(back.cursor).toBeUndefined(); + expect(pageNumber(back.trail)).toBe(1); + expect(hasPreviousPage(back.trail)).toBe(false); + }); + + test("a DEEP LINK seeds a page whose number is not knowable", () => { + // The hazard this exists for: an address names ONE page. The list can go + // forward from it and come back to it, but it cannot know whether it was + // page 2 or page 40, and inventing "page 2" because the stack is one deep + // would be a number an operator would reconcile against and lose. + const seeded = seedTrail("c-deep"); + expect(pageNumber(seeded)).toBeUndefined(); + // AND `Previous` IS NOT OFFERED, because popping would land page one, which + // is not the page before this one. + expect(hasPreviousPage(seeded)).toBe(false); + }); + + test("a deep link that pages forward can still come back to where it landed", () => { + const seeded = seedTrail("c-deep"); + const onward = pushedPage(seeded, "c-next"); + expect(hasPreviousPage(onward)).toBe(true); + expect(poppedPage(onward).cursor).toBe("c-deep"); + // Still not knowable, one page further along. + expect(pageNumber(onward)).toBeUndefined(); + }); + + test("an absent or empty seed is page one — a trimmed link is not a page", () => { + expect(seedTrail(undefined)).toEqual(FIRST_PAGE); + expect(seedTrail("")).toEqual(FIRST_PAGE); + }); + + test("the same cursor pushed twice is ONE page — the stack cannot outrun the walk", () => { + // AN INVARIANT, NOT A GUARD. The screens make the control unavailable while a + // request is in flight, but "unavailable" is a rendered state: two presses + // resolved inside one batch, a synthetic double event, or a service that + // answers two consecutive pages with the same `nextCursor` would each push + // the same token twice and leave the position quietly one too deep — which + // is the one defect a page number exists to avoid. + const once = pushedPage(FIRST_PAGE, "c2"); + expect(pushedPage(once, "c2")).toEqual(once); + // A DIFFERENT cursor is a different page and is pushed normally. + expect(pageNumber(pushedPage(once, "c3"))).toBe(3); + }); + + test("the stack and the cursor agree, or the move is inert", () => { + // `Previous` is offered only when there is a page recorded behind this one, + // and every path that clears the cursor (a filter apply, a refused deep + // link) resets the stack in the same commit. Should the two ever disagree + // anyway, the pop cannot invent a page that was never walked to: it answers + // with the page it is already on. + expect(hasPreviousPage(seedTrail("c-deep"))).toBe(false); + expect(poppedPage(seedTrail("c-deep")).cursor).toBe("c-deep"); + }); + + test("popping a stack with nowhere to go changes nothing", () => { + // Total rather than partial: the controls guard this, and a helper that + // threw (or silently invented page one) would make the guard load-bearing. + expect(poppedPage(FIRST_PAGE).trail).toEqual(FIRST_PAGE); + const seeded = seedTrail("c-deep"); + expect(poppedPage(seeded).trail).toEqual(seeded); + }); +}); + +describe("what the pager offers, and what it says", () => { + const base = { rows: 25, total: 137, pageSize: 25, busy: false, withdrawn: false }; + + test("page one of six: forward only, and the dimmed control says why", () => { + const view = pagerView({ ...base, trail: FIRST_PAGE, hasNext: true }); + expect(view.visible).toBe(true); + expect(view.position).toBe("Page 1 of 6"); + expect(view.previous.unavailable).toBe(true); + expect(view.previous.title).toBe(PREVIOUS_AT_START_TITLE); + expect(view.next.unavailable).toBe(false); + expect(view.next.title).toBeUndefined(); + }); + + test("the LAST page is the page count, whatever the arithmetic says", () => { + // This render has DIRECT evidence — the service returned no cursor after + // these rows — while a derived count is arithmetic over two statements + // taken at different moments. Direct evidence wins, and it is also the only + // thing that can answer at all when the service sends no total. + const walked = pushedPage(pushedPage(FIRST_PAGE, "c2"), "c3"); + expect(pagerView({ ...base, total: 70, trail: walked, hasNext: false }).position).toBe( + "Page 3 of 3", + ); + expect(pagerView({ ...base, total: undefined, trail: walked, hasNext: false }).position).toBe( + "Page 3 of 3", + ); + }); + + test("a page count that OUTRUNS the last page is a disagreement, and dashes", () => { + // THE CASE THE OVERRIDE GOT WRONG. Standing on page 3 with no cursor after + // it while the count implies six pages is two statements that cannot both + // be true — a concurrent write between the count and the page, or a service + // disagreeing with itself. "Page 3 of 3" beside "137 orders" picks a winner + // this render has no grounds to pick, so it states neither. + const walked = pushedPage(pushedPage(FIRST_PAGE, "c2"), "c3"); + expect(pagerView({ ...base, trail: walked, hasNext: false }).position).toBe("Page 3 of —"); + }); + + test("an accumulated window states its RANGE, and `Next` says what it costs", () => { + // Fifty rows beginning at page one are not "Page 2", and pressing `Next` + // over them releases the pages above — said in front of the click rather + // than discovered after it. + const view = pagerView({ + ...base, + rows: 50, + trail: pushedPage(FIRST_PAGE, "c2"), + hasNext: true, + span: 2, + }); + expect(view.position).toBe("Pages 1–2 of 6"); + expect(view.next.title).toBe(NEXT_RELEASES_SCAN_TITLE); + // With one page on screen there is nothing to release and nothing to warn + // about. + expect(pagerView({ ...base, trail: FIRST_PAGE, hasNext: true }).next.title).toBeUndefined(); + }); + + test("NO total is an em dash, never `of 1` and never `of 0`", () => { + const view = pagerView({ ...base, total: undefined, trail: FIRST_PAGE, hasNext: true }); + expect(view.position).toBe("Page 1 of —"); + expect(view.position).not.toContain("of 1"); + expect(view.position).not.toContain("of 0"); + }); + + test("a deep-linked page dims Previous and says it is the address's fault, not an error", () => { + const view = pagerView({ ...base, trail: seedTrail("c-deep"), hasNext: true }); + expect(view.previous.unavailable).toBe(true); + expect(view.previous.title).toBe(PREVIOUS_UNWALKED_TITLE); + expect(view.position).toBe("Page — of 6"); + }); + + test("the last page dims Next and says so", () => { + const view = pagerView({ ...base, trail: pushedPage(FIRST_PAGE, "c2"), hasNext: false }); + expect(view.next.unavailable).toBe(true); + expect(view.next.title).toBe(NEXT_AT_END_TITLE); + expect(view.previous.unavailable).toBe(false); + }); + + test("a request in flight dims both WITHOUT claiming a reason it does not have", () => { + const view = pagerView({ + ...base, + trail: pushedPage(FIRST_PAGE, "c2"), + hasNext: true, + busy: true, + }); + expect(view.previous.unavailable).toBe(true); + expect(view.next.unavailable).toBe(true); + // Busy is not "first page" and not "last page": no title is the honest + // answer for a control that is merely momentarily unavailable. + expect(view.previous.title).toBeUndefined(); + expect(view.next.title).toBeUndefined(); + }); + + test("a walked stack survives a round trip through a history entry", () => { + // What the screens store and `popstate` reads back — the whole reason Back + // onto page four still knows it is page four. + const walked = pushedPage(pushedPage(FIRST_PAGE, "c2"), "c3"); + expect(readTrailState({ ottaOrder: null, [PAGE_STATE_KEY]: trailState(walked) })).toEqual( + walked, + ); + expect(readTrailState(entryState(null, { ottaOrder: null }, seedTrail("c-deep")))).toEqual( + seedTrail("c-deep"), + ); + }); + + test("every writer MERGES into the entry rather than composing a fresh one", () => { + // FIVE WRITERS, ONE SHAPE. A key added for one of them is silently absent + // from the other four, and the loss only surfaces two traversals later as a + // pager that has forgotten where it is — so none of them may compose an + // object literal of its own. + const entry = entryState({ ottaOrder: "o-1", hostKey: 7 }, { ottaOrder: null }); + expect(entry["hostKey"]).toBe(7); + expect(entry["ottaOrder"]).toBeNull(); + // A writer with no opinion about the page leaves whatever is there alone. + const walked = pushedPage(FIRST_PAGE, "c2"); + const carried = entryState(entryState(null, {}, walked), { ottaOrder: "o-9" }); + expect(readTrailState(carried)).toEqual(walked); + // And one WITH an opinion states it. + expect(readTrailState(entryState(carried, {}, FIRST_PAGE))).toEqual(FIRST_PAGE); + }); + + test("an entry with no stack, a malformed one, or a hostile one is a deep link", () => { + // `history.state` is the same kind of input a URL is, and it is not a + // trusted channel: anything sharing this document can write it, it survives + // every reload, and it may have been written by an older build. A shape + // that does not match is "no stack", never a throw inside a `popstate` + // listener and never a value taken on faith. + expect(readTrailState(null)).toBeNull(); + expect(readTrailState({ ottaOrder: null })).toBeNull(); + expect(readTrailState({ [PAGE_STATE_KEY]: { cursors: "c2", grounded: true } })).toBeNull(); + expect(readTrailState({ [PAGE_STATE_KEY]: { cursors: [1, 2], grounded: true } })).toBeNull(); + expect(readTrailState({ [PAGE_STATE_KEY]: { cursors: [""], grounded: true } })).toBeNull(); + expect(readTrailState({ [PAGE_STATE_KEY]: { cursors: [] } })).toBeNull(); + expect(readTrailState({ [PAGE_STATE_KEY]: "cursors" })).toBeNull(); + expect(readTrailState({ [PAGE_STATE_KEY]: [] })).toBeNull(); + expect(readTrailState("{}")).toBeNull(); + expect(readTrailState(JSON.parse('{"ottaPage":{"cursors":["c2"]}}'))).toBeNull(); + // A WELL-FORMED ONE IS COPIED, never aliased: the caller holds a value, not + // a window onto an object whatever else shares this document can still + // write. + const shared = { [PAGE_STATE_KEY]: { cursors: ["c2"], grounded: true } }; + const read = readTrailState(shared); + shared[PAGE_STATE_KEY].cursors.push("c3"); + expect(read?.cursors).toEqual(["c2"]); + }); + + test("nothing to page is no pager at all", () => { + // A collection that fits on one page must not grow two dead controls and a + // line saying `Page 1 of 1`. + expect(pagerView({ ...base, trail: FIRST_PAGE, hasNext: false }).visible).toBe(false); + }); + + test("a withdrawn pager is withdrawn whatever the stack says", () => { + // The failure and the paging-stopped states take the whole control away — + // see the DOM half. + expect( + pagerView({ ...base, trail: pushedPage(FIRST_PAGE, "c2"), hasNext: true, withdrawn: true }) + .visible, + ).toBe(false); + }); +}); + +// ── the wiring ─────────────────────────────────────────────────────────────── + +const VOCABULARY = { + statuses: ["paid", "failed"], + statusAny: "any", + periods: [{ key: "last30", label: "Last 30 days" }], + cancellationReasons: [], + oneClickCancellationReasons: [], + reconciliationOutcomes: [], + pageLimit: 25, +}; + +const PRODUCTS_VOCABULARY = { + statuses: [{ value: "any", label: "Any status" }], + kinds: [{ value: "any", label: "Any kind" }], + any: "any", + pageLimit: 25, +}; + +/** Service-shaped opaque tokens: base64URL of `{pos, filter, limit}`, which is + * what the route emits and what an address therefore has to survive. */ +function token(payload: unknown): string { + return btoa(JSON.stringify(payload)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +const PAGE_TWO = token({ pos: { createdAt: "2026-03-02T10:20:00.000Z", id: "o-25" }, limit: 25 }); +const PAGE_THREE = token({ pos: { createdAt: "2026-03-02T10:40:00.000Z", id: "o-50" }, limit: 25 }); +const PAGE_FOUR = token({ pos: { createdAt: "2026-03-02T11:00:00.000Z", id: "o-75" }, limit: 25 }); + +function order(id: string) { + return { + id, + state: "paid", + currency: "USD", + buyerRef: `buyer-${id}`, + customerId: null, + paymentMethod: null, + createdAt: "2026-01-01T00:00:00.000Z", + totalCents: 1234, + reconciliationFlag: null, + }; +} + +function product(productId: string) { + return { + productId, + sku: `SKU-${productId}`, + title: `Product ${productId}`, + priceCents: 900, + currency: "USD", + productKind: "physical", + active: true, + deletedAt: null, + onHand: 4, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +function ids(prefix: string, from: number, to: number): string[] { + const out: string[] = []; + for (let n = from; n <= to; n += 1) out.push(`${prefix}-${String(n)}`); + return out; +} + +function envelope(data: unknown): Response { + return new Response(JSON.stringify({ data }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +interface Request { + readonly resource?: string; + readonly cursor?: string; + readonly orderId?: string; + readonly filter?: { readonly status?: string }; +} + +let asked: Request[] = []; + +/** A refusal, in the ONE shape every failure reaches this console as: a rejected + * token, an expired session, a 500 and a dead connection are all this value by + * the time a screen sees them. */ +function refusal(subject: string, status = 500): Response { + return envelope({ + ok: false, + title: `${subject} (HTTP ${String(status)})`, + description: "The request was not completed.", + }); +} + +function serve(handler: (request: Request) => Response): void { + apiFetch.mockImplementation((_input, init) => { + const request = JSON.parse(String(init?.body ?? "{}")) as Request; + asked.push(request); + return Promise.resolve(handler(request)); + }); +} + +/** Pages of 25, each reachable only with the one before it, and the service's + * exact count of the set beside them: 137 rows at 25 a page is SIX pages, the + * last one short. Every page here has another behind it, so `M` is the derived + * figure throughout and the last-page rule is exercised on its own fixture + * below rather than accidentally here. */ +function serveOrders(opts: { total?: number } = { total: 137 }): void { + serve((request) => { + const total = opts.total; + const body = (orders: readonly unknown[], nextCursor: string | null) => + envelope({ + ok: true, + orders, + nextCursor, + ...(total !== undefined ? { total } : {}), + vocabulary: VOCABULARY, + }); + if (request.cursor === undefined) return body(ids("o", 1, 25).map(order), PAGE_TWO); + if (request.cursor === PAGE_TWO) return body(ids("o", 26, 50).map(order), PAGE_THREE); + if (request.cursor === PAGE_THREE) return body(ids("o", 51, 75).map(order), PAGE_FOUR); + return body(ids("o", 76, 100).map(order), null); + }); +} + +function element(view: Mounted, testId: string): HTMLElement { + const found = view.container.querySelector(`[data-testid="${testId}"]`); + if (found === null) throw new Error(`no ${testId} on the screen`); + return found; +} + +function absent(view: Mounted, testId: string): boolean { + return view.container.querySelector(`[data-testid="${testId}"]`) === null; +} + +function rowIds(view: Mounted, testId: string): (string | null)[] { + return [...view.container.querySelectorAll(`[data-testid="${testId}"]`)].map((tr) => + tr.getAttribute("data-row-id"), + ); +} + +function press(view: Mounted, testId: string): Promise { + return fire(element(view, testId), "click"); +} + +function position(view: Mounted, screen: string): string { + return element(view, `${screen}-page-position`).textContent ?? ""; +} + +/** Settle the request the last interaction issued. */ +async function settle(): Promise { + for (let round = 0; round < 3; round += 1) { + await React.act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +function retype(field: HTMLInputElement | HTMLSelectElement, value: string): void { + const proto = field instanceof HTMLSelectElement ? HTMLSelectElement : HTMLInputElement; + Object.getOwnPropertyDescriptor(proto.prototype, "value")?.set?.call(field, value); + field.dispatchEvent( + new Event(field instanceof HTMLSelectElement ? "change" : "input", { bubbles: true }), + ); +} + +function search(): URLSearchParams { + return new URLSearchParams(window.location.search); +} + +let view: Mounted | undefined; + +beforeEach(() => { + asked = []; + apiFetch.mockReset(); + window.history.replaceState(null, "", "/orders"); +}); + +afterEach(async () => { + await view?.unmount(); + view = undefined; + window.history.replaceState(null, "", "/"); +}); + +test("Next walks forward one page at a time, and the address walks with it", async () => { + serveOrders(); + view = await mount(); + await settle(); + + expect(position(view, "orders")).toBe("Page 1 of 6"); + expect(rowIds(view, "orders-row")).toEqual(ids("o", 1, 25)); + + await press(view, "orders-next"); + await settle(); + // THE PAGE REPLACES, it does not accumulate: `Next` moves a window, while + // `Load more` extends one, and the two are different acts. + expect(rowIds(view, "orders-row")).toEqual(ids("o", 26, 50)); + expect(position(view, "orders")).toBe("Page 2 of 6"); + expect(search().get("cursor")).toBe(PAGE_TWO); + + await press(view, "orders-next"); + await settle(); + expect(rowIds(view, "orders-row")).toEqual(ids("o", 51, 75)); + expect(position(view, "orders")).toBe("Page 3 of 6"); + expect(search().get("cursor")).toBe(PAGE_THREE); +}); + +test("Previous RE-REQUESTS the popped cursor — it does not restore rows from memory", async () => { + /* + * THE CHOICE, PINNED. The stack holds CURSORS, so going back is a request + * under a token the service already issued: exact, and answered with the store + * as it stands NOW. Restoring the rows a page arrived with would be one fewer + * round trip and would show an operator a page that may be minutes stale — and + * would disagree with a reload of the very same address, which fetches. + */ + let visits = 0; + serve((request) => { + const body = (orders: readonly unknown[], nextCursor: string | null) => + envelope({ ok: true, orders, nextCursor, total: 137, vocabulary: VOCABULARY }); + if (request.cursor === undefined) return body(ids("o", 1, 25).map(order), PAGE_TWO); + if (request.cursor === PAGE_TWO) { + visits += 1; + // The second visit to page two sees a row that was edited in between. + return body( + visits === 1 ? ids("o", 26, 50).map(order) : ids("o", 26, 49).map(order), + PAGE_THREE, + ); + } + return body(ids("o", 51, 75).map(order), null); + }); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + await press(view, "orders-next"); + await settle(); + + asked = []; + await press(view, "orders-prev"); + await settle(); + + // A REQUEST WENT OUT, carrying the popped cursor verbatim. + expect(asked).toHaveLength(1); + expect(asked[0]?.cursor).toBe(PAGE_TWO); + // And the rows are the ones the store holds now, not the ones this screen + // had in hand. + expect(rowIds(view, "orders-row")).toEqual(ids("o", 26, 49)); + expect(position(view, "orders")).toBe("Page 2 of 6"); + expect(search().get("cursor")).toBe(PAGE_TWO); +}); + +test("Previous off the bottom of the stack is page one, cursor and all", async () => { + serveOrders(); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + + asked = []; + await press(view, "orders-prev"); + await settle(); + + // NO CURSOR ON THE WIRE — page one is the absence of one, not a token for it. + expect(asked).toHaveLength(1); + expect(asked[0]?.cursor).toBeUndefined(); + expect(rowIds(view, "orders-row")).toEqual(ids("o", 1, 25)); + expect(position(view, "orders")).toBe("Page 1 of 6"); + // And the address is corrected through the same one path the list has always + // announced its page on. + expect(search().get("cursor")).toBeNull(); +}); + +test("an unavailable control is focusable, explains itself, and issues nothing", async () => { + serveOrders(); + view = await mount(); + await settle(); + + const previous = element(view, "orders-prev"); + // `aria-disabled`, NOT `disabled`: a control that leaves the tab order under + // the operator's fingers takes the focus with it, and `Next` becomes + // unavailable at exactly the moment it is being pressed. + expect(previous.getAttribute("aria-disabled")).toBe("true"); + expect(previous.hasAttribute("disabled")).toBe(false); + expect(previous.getAttribute("title")).toBe(PREVIOUS_AT_START_TITLE); + expect(previous.className).toContain("otta-focusable"); + + asked = []; + await press(view, "orders-prev"); + await settle(); + expect(asked).toHaveLength(0); +}); + +test("paging onto the LAST page keeps the focus it was pressed with", async () => { + // Two pages: 25 then 5, and an exact count of 30. `Next` goes unavailable the + // moment it lands, which is the case a `disabled` attribute would answer by + // dropping focus to `document.body`. + serve((request) => + envelope( + request.cursor === undefined + ? { + ok: true, + orders: ids("o", 1, 25).map(order), + nextCursor: PAGE_TWO, + total: 30, + vocabulary: VOCABULARY, + } + : { + ok: true, + orders: ids("o", 26, 30).map(order), + nextCursor: null, + total: 30, + vocabulary: VOCABULARY, + }, + ), + ); + view = await mount(); + await settle(); + + const next = element(view, "orders-next"); + next.focus(); + await press(view, "orders-next"); + await settle(); + + expect(position(view, "orders")).toBe("Page 2 of 2"); + expect(element(view, "orders-next").getAttribute("aria-disabled")).toBe("true"); + expect(element(view, "orders-next").getAttribute("title")).toBe(NEXT_AT_END_TITLE); + // THE FOCUS IS STILL THERE, on the same element. + expect(document.activeElement).toBe(element(view, "orders-next")); +}); + +test("a service with no exact count still pages — M is an em dash, never `of 1`", async () => { + serveOrders({}); + view = await mount(); + await settle(); + + expect(position(view, "orders")).toBe("Page 1 of —"); + await press(view, "orders-next"); + await settle(); + expect(position(view, "orders")).toBe("Page 2 of —"); + // And the count line beside it keeps its own hedge rather than borrowing a + // figure neither of them has. + expect(element(view, "orders-intro").textContent).toContain("25 orders on this page"); +}); + +test("a deep link cannot know its page: Previous is dimmed and says why", async () => { + serveOrders(); + window.history.replaceState(null, "", `/orders?cursor=${encodeURIComponent(PAGE_TWO)}`); + view = await mount(); + await settle(); + + expect(rowIds(view, "orders-row")).toEqual(ids("o", 26, 50)); + // N IS ABSENT, and absent renders as a dash — the address named one page, and + // nothing about it says which one. + expect(position(view, "orders")).toBe("Page — of 6"); + const previous = element(view, "orders-prev"); + expect(previous.getAttribute("aria-disabled")).toBe("true"); + expect(previous.getAttribute("title")).toBe(PREVIOUS_UNWALKED_TITLE); + + // FORWARD FROM A DEEP LINK STILL COMES BACK. The seeded page is on the stack + // even though its number is not, so `Previous` returns to it rather than to + // page one. + await press(view, "orders-next"); + await settle(); + expect(position(view, "orders")).toBe("Page — of 6"); + asked = []; + await press(view, "orders-prev"); + await settle(); + expect(asked[0]?.cursor).toBe(PAGE_TWO); + expect(rowIds(view, "orders-row")).toEqual(ids("o", 26, 50)); + expect(search().get("cursor")).toBe(PAGE_TWO); +}); + +test("applying a filter RESETS the stack — Previous can never cross a predicate", async () => { + serveOrders(); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + await press(view, "orders-next"); + await settle(); + expect(position(view, "orders")).toBe("Page 3 of 6"); + + await React.act(async () => { + retype(element(view as Mounted, "filter-status") as HTMLSelectElement, "failed"); + }); + await press(view, "apply-filters"); + await settle(); + + // PAGE ONE OF A NEW SET, with nothing behind it. A stack that survived would + // hand `Previous` a token issued under the predicate the operator just left — + // which the service refuses, and which would land them on page one with a + // notice for a journey they thought they understood. + expect(position(view, "orders")).toBe("Page 1 of 6"); + expect(element(view, "orders-prev").getAttribute("aria-disabled")).toBe("true"); + expect(search().get("cursor")).toBeNull(); + expect(asked.at(-1)?.filter?.status).toBe("failed"); +}); + +test("Load more and the pager share one position: the scan is where the pager is", async () => { + serveOrders(); + view = await mount(); + await settle(); + + // LOAD MORE ADVANCES THE POSITION TOO. Both controls move to the next page; + // they differ only in whether the rows above stay on screen. + await press(view, "orders-load-more"); + await settle(); + expect(rowIds(view, "orders-row")).toHaveLength(50); + // THE WINDOW, NOT ITS LAST PAGE: fifty rows beginning at page one are not + // "Page 2", and an operator reading the top of that list would be told the + // wrong number. + expect(position(view, "orders")).toBe("Pages 1–2 of 6"); + expect(element(view, "orders-intro").textContent).toContain("137 orders"); + + // NEXT FROM AN ACCUMULATED SCAN pages on from the scan's last cursor, and the + // stack records where it came from. The window collapses to one page — a + // pager step is a move, not an extension — which is the stated cost of having + // both controls. + await press(view, "orders-next"); + await settle(); + expect(rowIds(view, "orders-row")).toEqual(ids("o", 51, 75)); + expect(position(view, "orders")).toBe("Page 3 of 6"); + + asked = []; + await press(view, "orders-prev"); + await settle(); + expect(asked[0]?.cursor).toBe(PAGE_TWO); + expect(position(view, "orders")).toBe("Page 2 of 6"); +}); + +test("paging stopped withdraws the pager, not just the offer to continue", async () => { + let call = 0; + serve((request) => { + call += 1; + const body = (orders: readonly unknown[], extra: Record = {}) => + envelope({ + ok: true, + orders, + nextCursor: PAGE_TWO, + total: 137, + vocabulary: VOCABULARY, + ...extra, + }); + if (request.cursor === undefined) return body(ids("o", 1, 25).map(order)); + if (call === 2) return body(ids("o", 26, 50).map(order)); + return body(ids("o", 1, 25).map(order), { cursorRejected: true }); + }); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + expect(absent(view, "orders-pager")).toBe(false); + + await press(view, "orders-next"); + await settle(); + + // The rows on screen are untouched and paging is over — and a `Previous` left + // standing would page relative to a position the screen has just disowned in + // the address. + expect(rowIds(view, "orders-row")).toEqual(ids("o", 26, 50)); + expect(absent(view, "orders-pager")).toBe(true); + expect(absent(view, "orders-load-more")).toBe(true); + expect(absent(view, "orders-paging-stopped")).toBe(false); +}); + +test("a refused deep link comes back as page one, with a stack to match", async () => { + serve((request) => + envelope({ + ok: true, + orders: ids("o", 1, 25).map(order), + nextCursor: PAGE_TWO, + total: 137, + vocabulary: VOCABULARY, + ...(request.cursor !== undefined ? { cursorRejected: true } : {}), + }), + ); + window.history.replaceState(null, "", "/orders?cursor=tampered"); + view = await mount(); + await settle(); + + // The rows ARE page one, so the position must say so rather than keeping the + // unknowable page the address asked for. + expect(absent(view, "orders-cursor-reset")).toBe(false); + expect(position(view, "orders")).toBe("Page 1 of 6"); + expect(element(view, "orders-prev").getAttribute("title")).toBe(PREVIOUS_AT_START_TITLE); +}); + +test("products pages the same way, from the same stack", async () => { + serve((request) => { + const stock = { threshold: 5, unreadable: false, filterUnavailable: false }; + const body = (products: readonly unknown[], nextCursor: string | null) => + envelope({ + ok: true, + products, + nextCursor, + total: 137, + stock, + vocabulary: PRODUCTS_VOCABULARY, + }); + if (request.cursor === undefined) return body(ids("p", 1, 25).map(product), PAGE_TWO); + if (request.cursor === PAGE_TWO) return body(ids("p", 26, 50).map(product), PAGE_THREE); + return body(ids("p", 51, 75).map(product), PAGE_FOUR); + }); + window.history.replaceState(null, "", "/products"); + view = await mount(); + await settle(); + + expect(position(view, "products")).toBe("Page 1 of 6"); + await press(view, "products-next"); + await settle(); + expect(rowIds(view, "products-row")).toEqual(ids("p", 26, 50)); + expect(position(view, "products")).toBe("Page 2 of 6"); + expect(search().get("cursor")).toBe(PAGE_TWO); + + asked = []; + await press(view, "products-prev"); + await settle(); + expect(asked[0]?.cursor).toBeUndefined(); + expect(rowIds(view, "products-row")).toEqual(ids("p", 1, 25)); + expect(position(view, "products")).toBe("Page 1 of 6"); + expect(search().get("cursor")).toBeNull(); +}); + +// ── the browser's own Back, and the pager ──────────────────────────────────── + +/** A REAL traversal, not a hand-dispatched `popstate`: `happy-dom` resolves the + * entry, moves the address and delivers the event, all asynchronously. */ +async function traverse(direction: "back" | "forward"): Promise { + await React.act(async () => { + if (direction === "back") window.history.back(); + else window.history.forward(); + await new Promise((resolve) => setTimeout(resolve, 25)); + }); + await settle(); +} + +test("browser Back mid-walk keeps the position AND a live Previous", async () => { + /* + * THE DEFECT THIS CLOSES. The address carries one cursor, so a traversal used + * to land on a page with no stack behind it: the position fell to a dash and + * `Previous` dimmed, two presses into a scan, for no reason visible on screen. + * A history ENTRY is not a link, and can carry what a link must not. + */ + serveOrders(); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + await press(view, "orders-next"); + await settle(); + expect(position(view, "orders")).toBe("Page 3 of 6"); + + await traverse("back"); + + // STILL GROUNDED. The entry knew it was page two, so the pager does too. + expect(position(view, "orders")).toBe("Page 2 of 6"); + expect(search().get("cursor")).toBe(PAGE_TWO); + expect(element(view, "orders-prev").getAttribute("aria-disabled")).toBeNull(); + expect(element(view, "orders-prev").getAttribute("title")).toBeNull(); + expect(rowIds(view, "orders-row")).toEqual(ids("o", 26, 50)); + + // FORWARD RETURNS TO THE PAGE JUST LEFT, still knowing which one it is. + await traverse("forward"); + expect(position(view, "orders")).toBe("Page 3 of 6"); + expect(element(view, "orders-prev").getAttribute("aria-disabled")).toBeNull(); + + // AND PREVIOUS STILL WORKS from a traversed-to page, which is the half a + // dashed position would have taken away. (It PUSHES, so it truncates the + // forward entries — see the entry-kind test below.) + await traverse("back"); + asked = []; + await press(view, "orders-prev"); + await settle(); + expect(asked.at(-1)?.cursor).toBeUndefined(); + expect(position(view, "orders")).toBe("Page 1 of 6"); +}); + +test("Previous onto page one PUSHES — it is a journey, not a correction", async () => { + /* + * THE TWO MEANINGS THAT USED TO SHARE ONE VALUE. "No cursor" was read as "the + * list is correcting an address that would not open", which REPLACES the + * entry — right for a refused deep link, and wrong for an operator who + * deliberately stepped back, whose entry for the page they stepped FROM was + * being silently overwritten. + */ + serveOrders(); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + const depth = window.history.length; + + await press(view, "orders-prev"); + await settle(); + expect(search().get("cursor")).toBeNull(); + expect(window.history.length).toBe(depth + 1); + + // AND THE PAGE IT STEPPED FROM IS STILL BEHIND IT. + await traverse("back"); + expect(search().get("cursor")).toBe(PAGE_TWO); + expect(position(view, "orders")).toBe("Page 2 of 6"); +}); + +test("a refused deep link still CORRECTS in place, entry and stack together", async () => { + // The other half of the same discriminator: a page that would not open must + // not bury the entry the operator is standing on under one they never asked + // for, or their Back walks into the refused link they just arrived from. + serve((request) => + envelope({ + ok: true, + orders: ids("o", 1, 25).map(order), + nextCursor: PAGE_TWO, + total: 137, + vocabulary: VOCABULARY, + ...(request.cursor !== undefined ? { cursorRejected: true } : {}), + }), + ); + window.history.replaceState(null, "", "/orders?cursor=tampered"); + const depth = window.history.length; + view = await mount(); + await settle(); + + expect(window.history.length).toBe(depth); + expect(search().get("cursor")).toBeNull(); + expect(position(view, "orders")).toBe("Page 1 of 6"); +}); + +test("a FAILED Previous onto page one leaves the rows exactly where they are", async () => { + /* + * THE ROWS ARE NOT DISPROVED BY THE MOVE THAT FAILED. `Previous` onto page one + * sends no cursor, and reading "was there a cursor?" as "was this a fresh + * load?" made this failure clear a screenful of rows that were still a true + * answer to the query that produced them — the exact opposite of the rule a + * failed `Load more` already follows. + */ + let calls = 0; + serve((request) => { + calls += 1; + if (calls > 2) return refusal("Orders are unavailable", 500); + return envelope({ + ok: true, + orders: + request.cursor === undefined ? ids("o", 1, 25).map(order) : ids("o", 26, 50).map(order), + nextCursor: request.cursor === undefined ? PAGE_TWO : PAGE_THREE, + total: 137, + vocabulary: VOCABULARY, + }); + }); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + expect(rowIds(view, "orders-row")).toEqual(ids("o", 26, 50)); + + await press(view, "orders-prev"); + await settle(); + + // THE ROWS STAND, and the refusal is drawn beside them rather than over the + // space they used to occupy. + expect(rowIds(view, "orders-row")).toEqual(ids("o", 26, 50)); + expect(absent(view, "orders-load-more-failure")).toBe(false); + expect(absent(view, "orders-failure")).toBe(true); + // The filter panel and the count line are untouched, because nothing about + // them was disproved either. + expect(element(view, "orders-intro").textContent).toContain("137 orders"); +}); + +test("an unavailable control carries its reason where a screen reader will read it", async () => { + // `title` is a POINTER affordance; a described-by node is read out with the + // control's name every time, by every screen reader, however the operator + // arrived at it. + serveOrders(); + view = await mount(); + await settle(); + + const previous = element(view, "orders-prev"); + const describedBy = previous.getAttribute("aria-describedby"); + expect(describedBy).not.toBeNull(); + const reason = view.container.querySelector(`#${CSS.escape(String(describedBy))}`); + expect(reason?.textContent).toBe(PREVIOUS_AT_START_TITLE); + expect(reason?.className).toContain("otta-sr-only"); + // The tooltip is a bonus, not the mechanism. + expect(previous.getAttribute("title")).toBe(PREVIOUS_AT_START_TITLE); + + // AND AN AVAILABLE CONTROL DESCRIBES NOTHING — a permanent "you may press + // this" would be read out on every visit and mean nothing. + expect(element(view, "orders-next").getAttribute("aria-describedby")).toBeNull(); +}); + +test("products: a pager step keeps the banner a settings blip raised", async () => { + /* + * THE SHARPEST DEFECT THE PAGER INTRODUCED, and the reason the latch rides on + * the CONTINUATION rather than on the merge. `filterUnavailable` is page one's + * answer to "was the low-stock filter ever applied to what you are looking + * at"; every request carrying a cursor reports `false` by contract, because + * the predicate rode inside the opaque token. Reading that `false` as an + * answer drops the banner at the click of `Next` and starts captioning every + * product in the catalog as low stock. + */ + serve((request) => { + const blind = { threshold: null, unreadable: false, filterUnavailable: true }; + const seeing = { threshold: 5, unreadable: false, filterUnavailable: false }; + return envelope( + request.cursor === undefined + ? { + ok: true, + products: ids("p", 1, 25).map(product), + nextCursor: PAGE_TWO, + stock: blind, + vocabulary: PRODUCTS_VOCABULARY, + } + : { + ok: true, + products: ids("p", 26, 50).map(product), + nextCursor: PAGE_THREE, + // The contractual `false` plus a total for the WHOLE catalog — the + // pair that must not be believed on a continuation. + total: 137, + stock: seeing, + vocabulary: PRODUCTS_VOCABULARY, + }, + ); + }); + window.history.replaceState(null, "", "/products?low=1"); + view = await mount(); + await settle(); + expect(absent(view, "products-stock-degraded")).toBe(false); + + await press(view, "products-next"); + await settle(); + + // THE BANNER STANDS, page one's answer intact. + expect(rowIds(view, "products-row")).toEqual(ids("p", 26, 50)); + expect(absent(view, "products-stock-degraded")).toBe(false); + // AND THE TOTAL IS STILL WITHHELD: 137 counts every product while the + // merchant asked for the low-stock ones, so neither the caption nor the page + // count may state it. + expect(element(view, "products-intro").textContent).not.toContain("137"); + expect(position(view, "products")).toBe("Page 2 of —"); +}); + +// ── what a request with NO cursor on the wire is ───────────────────────────── + +/** Page one, page two, and a THIRD answer for the page-one request the pager + * makes on the way back — which is where the wire and the operator's intent + * can disagree. */ +function servePageOneAgain(again: (call: number) => Record): void { + let call = 0; + serve((request) => { + call += 1; + if (request.cursor === undefined && call > 1) { + return envelope({ ok: true, vocabulary: VOCABULARY, ...again(call) }); + } + return envelope({ + ok: true, + orders: + request.cursor === undefined ? ids("o", 1, 25).map(order) : ids("o", 26, 50).map(order), + nextCursor: request.cursor === undefined ? PAGE_TWO : PAGE_THREE, + total: 137, + vocabulary: VOCABULARY, + }); + }); +} + +test("Previous onto page one is PAGE ONE — the count may state the whole set", async () => { + /* + * THE CLASSIFIER READS THE WIRE, NOT THE INTENT. A request carrying no cursor + * comes back as the first page under the current predicate, whoever asked for + * it. Calling it a `replace` because the OPERATOR asked for a page captioned a + * render that IS page one as `firstPage: false` — which puts the "on this + * page" hedge on a count this render can prove outright. + */ + servePageOneAgain(() => ({ orders: ids("o", 1, 3).map(order), nextCursor: null })); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + + await press(view, "orders-prev"); + await settle(); + + // The collection shrank under the operator; page one is now three rows with + // nothing behind it, and this render is entitled to say so without a hedge. + expect(rowIds(view, "orders-row")).toEqual(ids("o", 1, 3)); + expect(element(view, "orders-intro").textContent).toContain("3 orders ·"); + expect(element(view, "orders-intro").textContent).not.toContain("on this page"); +}); + +test("Previous onto an EMPTY page one gets the whole-collection words, not the page-scoped ones", async () => { + // The other half of the same `firstPage`. "Nothing on this page" is the copy + // for a page that ran off the end; page one running empty is the collection + // being empty, which is a different sentence with a different offer. + servePageOneAgain(() => ({ orders: [], nextCursor: null })); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + + await press(view, "orders-prev"); + await settle(); + + expect(absent(view, "orders-empty")).toBe(false); + expect(absent(view, "orders-page-zero")).toBe(true); +}); + +/** Pages of products where the low-stock threshold can be made unreadable per + * request — the settings blip whose banner the latch exists for. */ +function serveProductsBlips(blindOn: (call: number, cursor: string | undefined) => boolean): void { + let call = 0; + serve((request) => { + call += 1; + const blind = blindOn(call, request.cursor); + return envelope({ + ok: true, + products: + request.cursor === undefined ? ids("p", 1, 25).map(product) : ids("p", 26, 50).map(product), + nextCursor: request.cursor === undefined ? PAGE_TWO : PAGE_THREE, + ...(blind ? {} : { total: 137 }), + stock: blind + ? { threshold: null, unreadable: false, filterUnavailable: true } + : { threshold: 5, unreadable: false, filterUnavailable: false }, + vocabulary: PRODUCTS_VOCABULARY, + }); + }); +} + +test("products: Previous onto page one CLEARS a banner the blip has stopped causing", async () => { + // THE LATCH MUST BE ABLE TO END. A banner that only a filter change could + // dismiss would sit over a catalogue whose threshold has been readable for an + // hour, and the merchant has no way to tell a stale warning from a live one. + serveProductsBlips((call) => call === 1); + window.history.replaceState(null, "", "/products?low=1"); + view = await mount(); + await settle(); + expect(absent(view, "products-stock-degraded")).toBe(false); + + await press(view, "products-next"); + await settle(); + // Still latched across the continuation — the contractual `false` is not an + // answer. + expect(absent(view, "products-stock-degraded")).toBe(false); + + await press(view, "products-prev"); + await settle(); + + // PAGE ONE, ANSWERED AUTHORITATIVELY, and the threshold read fine this time. + expect(absent(view, "products-stock-degraded")).toBe(true); + // AND THE NOUN FOLLOWS THE LATCH (F29): the rows really are the low-stock + // ones now, so they are named as such, and the exact count comes back with + // them. + expect(element(view, "products-intro").textContent).toContain("137 low-stock products"); +}); + +test("products: a blip ON the Previous request RAISES the banner", async () => { + // The same rule from the other side: page one may raise as well as clear, and + // a request that carried no cursor is page one. + serveProductsBlips((call) => call === 3); + window.history.replaceState(null, "", "/products?low=1"); + view = await mount(); + await settle(); + expect(absent(view, "products-stock-degraded")).toBe(true); + + await press(view, "products-next"); + await settle(); + expect(absent(view, "products-stock-degraded")).toBe(true); + + await press(view, "products-prev"); + await settle(); + + expect(absent(view, "products-stock-degraded")).toBe(false); + // AND THE NOUN GOES BACK TO THE PLAIN ONE (F29). These rows are every + // product, because the predicate never went out; calling twenty-five of them + // "low-stock products" is the exact mislabel that ruling exists to prevent, + // and it is what a latch read off a continuation produces here. + const intro = element(view, "products-intro").textContent ?? ""; + expect(intro).toContain("25 products on this page"); + expect(intro).not.toContain("low-stock"); + expect(intro).not.toContain("137"); +}); + +test("a deep link to the LAST page keeps its pager on screen", async () => { + // The pure tier pins the predicate; this pins that a real screen renders it. + // Neither control can move, which is exactly when the position is the only + // thing that can answer the question that operator arrived with. + serve(() => + envelope({ + ok: true, + orders: ids("o", 126, 137).map(order), + nextCursor: null, + total: 137, + vocabulary: VOCABULARY, + }), + ); + window.history.replaceState(null, "", `/orders?cursor=${encodeURIComponent(PAGE_THREE)}`); + view = await mount(); + await settle(); + + expect(absent(view, "orders-pager")).toBe(false); + expect(element(view, "orders-prev").getAttribute("aria-disabled")).toBe("true"); + expect(element(view, "orders-next").getAttribute("aria-disabled")).toBe("true"); + expect(position(view, "orders")).toBe("Page — of 6"); +}); + +// ── the record drill-in carries the page too ───────────────────────────────── + +/** The list fake plus the one record the drill-in opens. */ +function serveOrdersWithDetail(): void { + serve((request) => { + if (request.resource === "orders.detail") { + return envelope({ + ok: true, + order: { + id: String(request.orderId), + state: "paid", + currency: "USD", + paymentMethod: null, + buyerRef: "buyer@example.test", + customerId: null, + createdAt: "2026-01-01T00:00:00.000Z", + reconciliationFlag: null, + reconciliationResolution: null, + fulfillment: null, + cancellation: null, + shippingAddress: null, + totals: { + currency: "USD", + subtotalCents: 1234, + discountCents: 0, + shippingCents: 0, + taxCents: 0, + totalCents: 1234, + appliedCouponCode: null, + }, + lines: [], + }, + transitions: [], + customer: null, + timeline: null, + refunds: null, + notes: [], + vocabulary: VOCABULARY, + }); + } + const first = request.cursor === undefined; + return envelope({ + ok: true, + orders: first ? ids("o", 1, 25).map(order) : ids("o", 26, 50).map(order), + nextCursor: first ? PAGE_TWO : PAGE_THREE, + total: 137, + vocabulary: VOCABULARY, + }); + }); +} + +test("a record opened from page two, RELOADED, comes back to a list that knows its page", async () => { + /* + * THE ENTRY THE DRILL-IN PUSHES IS A LIST ENTRY WEARING A RECORD'S ADDRESS, + * and it has to carry the list's stack: the operator opened this record FROM + * page two. Composing that entry from `{ottaOrder: id}` alone — which is what + * it did — dropped the walk one click after it was earned, so `Back to orders` + * from a reloaded record landed on a page that could not name itself. + */ + serveOrdersWithDetail(); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + expect(position(view, "orders")).toBe("Page 2 of 6"); + + await fire(element(view, "order-link"), "click"); + await settle(); + expect(search().get("order")).toBe("o-26"); + + // A RELOAD: the document goes and comes back on the same entry, which is what + // `history.state` is for. + await view.unmount(); + view = await mount(); + await settle(); + expect(absent(view, "orders-back")).toBe(false); + + // `Back to orders` on a mount that pushed nothing REPLACES the address rather + // than popping — and must not take the stack with it. + await press(view, "orders-back"); + await settle(); + expect(position(view, "orders")).toBe("Page 2 of 6"); + expect(element(view, "orders-prev").getAttribute("aria-disabled")).toBeNull(); +}); + +test("Back and Forward around a record keep the list's position", async () => { + serveOrdersWithDetail(); + view = await mount(); + await settle(); + await press(view, "orders-next"); + await settle(); + + await fire(element(view, "order-link"), "click"); + await settle(); + + await traverse("back"); + expect(absent(view, "orders-pager")).toBe(false); + expect(position(view, "orders")).toBe("Page 2 of 6"); + expect(element(view, "orders-prev").getAttribute("aria-disabled")).toBeNull(); + + await traverse("forward"); + expect(search().get("order")).toBe("o-26"); + + await traverse("back"); + expect(position(view, "orders")).toBe("Page 2 of 6"); +});