Skip to content

Commit 9ad7272

Browse files
vedanshujainclaude
andcommitted
[Plugin] INC-12: row counts, designed empty states, Clear filters
Every list says how many, and a filter narrowed to nothing says so and offers the way back — Orders, Pricing & inventory and Coupons. THE INTRO LINE CARRIES THE COUNT, pluralized through `Intl.PluralRules` on the console's one pinned locale (the constant the date dialect already uses), so `1 order` / `17 orders` come from one place. AND IT IS HONEST ABOUT WHAT IT COUNTED. Every list port answers `{items, nextCursor}` and no screen has a total, so `17 orders` renders only when the page IS the set — first page of its filter, no next cursor. Everywhere else the same count reads `25 orders on this page`. Page 3 of 3 knows nothing about pages 1 and 2 (keyset paging carries no running offset and the scaffold does not accumulate one across stateless interactions), so it stays page-scoped too. A whole-store total needs the service to return one; a number an operator would reconcile against is not invented in a renderer. ZERO RENDERS NO COUNT — never `0 orders`. The state below says it in words, and a count line repeating it is "unknown rendered as 0". TWO ZERO STATES THAT NO LONGER READ ALIKE. An empty collection is not the operator's doing and is not addressed as though it were. A filter narrowed to nothing is a different event and now gets a heading, body copy and a `Clear filters` button where it used to get one line of `empty_text` under a table that was not rendered. The undo is attached to the state that needs it and to no other. THE WHOLE-COLLECTION WORDING IS GATED ON `firstPage`, exactly as the count is. "No orders yet" is a claim about the collection and page 2 of a scan has no standing to make one — page 1 had rows. Reachable in a live store, not only in theory: the look-ahead read that minted the cursor and the read that follows it straddle a concurrent delete, and the second comes back empty. A non-first-page zero falls through to page-scoped wording, offering nothing to click since nothing was filtered. `Clear filters` IS ONE ACT WITH ONE BUILDER — ACROSS THE THREE SCREENS THIS TOUCHES. The summary section's accessory and the new zero-state button come from one builder, so the label, the verb and the payload cannot drift on Orders, Pricing & inventory and Coupons. tax-page.ts and shipping-page.ts still hand-roll a byte-identical button and are deliberately untouched here (one PR, one thing) — they are the next-touch consolidation onto `clearFiltersButton`. The builder fires a BARE apply-filter (no values ⇒ the level's default filter) and carries the drill path in the button's own `value`, which keeps the re-render on the level the operator was standing on. A PAGE WITH ANOTHER PAGE BEHIND IT NEVER CLAIMS TO BE THE END. The pinned renderer collapses a zero-row table carrying `empty_text` to a bare <p> and takes "Load more" with it, so a mid-scan zero page keeps a headers-only table plus a note. Established for the low-stock filter; now the shared rule for every screen. BUILT ONCE: `listResult` in `scaffold/list-detail.ts` decides which outcome a render is in and the three screens supply wording only. The list level's `render` also receives `firstPage`, the one fact neither the honest-count rule nor the whole-collection gate can be derived without. TWO CONTAINMENT FIXES CARRIED ALONG. A cursor minted before the products filter grew its page-context wrapper arrived as a bare filter form and threw on the way into `render`; it now self-heals — the bare shape IS the form, so the operator's filter survives the deploy, and an unreadable one degrades to "no filter set" rather than to an exception (G5). And the cursor encoding is documented for what it carries: `fetchPage` and `render` share one filter object, so page context written during the fetch rides the next-page cursor too. Gate: full run green at the pre-review tree (180 files, 3179 tests, SQLite). Review take-its re-gated delta-scoped — lint, typecheck, format:check, and the three page suites plus four scaffold suites (7 files, 342 tests) all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq2ju7XBMf2rJsXBxSjE74
1 parent b917755 commit 9ad7272

9 files changed

Lines changed: 975 additions & 146 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
"@otta-sh/plugin": patch
3+
---
4+
5+
Every list says how many, and a filter narrowed to nothing says so and offers
6+
the way back — on Orders, Pricing & inventory and Coupons.
7+
8+
**The intro line carries the count.** `17 orders · Filter, open an order, and
9+
move it through its status flow.` The count is pluralized through
10+
`Intl.PluralRules` on the console's one pinned locale — the same constant the
11+
date dialect uses — so `1 order` and `17 orders` come from one place and a
12+
future viewer locale moves one knob.
13+
14+
**The count is honest about what it counted, because the wire is thin.** Every
15+
list port answers `{items, nextCursor}` — a page and a way to ask for the next
16+
one — and no screen has a total. So `17 orders` renders only when the page IS
17+
the set: the first page of its filter, with no next cursor. Everywhere else the
18+
same count reads `25 orders on this page`, which is the smaller claim and the
19+
true one. Page 3 of 3 knows nothing about pages 1 and 2 (keyset paging carries
20+
no running offset, and the scaffold deliberately does not accumulate one across
21+
stateless interactions), so it stays page-scoped too. A whole-store total needs
22+
the service to return one alongside `nextCursor`; until it does, a number an
23+
operator would reconcile against must not be invented here.
24+
25+
**Zero renders no count at all.** Never `0 orders` — at zero the state below
26+
says it in words, and a count line repeating it is "unknown rendered as 0" in a
27+
costume.
28+
29+
**Two zero states, and they no longer read alike.** A collection that is simply
30+
empty is not the operator's doing and is not addressed as though it were:
31+
`No orders yet — Orders appear here as buyers check out.` A filter narrowed to
32+
nothing is a different event and now gets a designed state of its own —
33+
heading, body copy, and a **Clear filters** button — where it used to get one
34+
line of `empty_text` under a table that was not rendered. The undo is attached
35+
to the state that needs it and to no other: an empty catalog offers nothing to
36+
clear, because there is no filter to blame.
37+
38+
**The whole-collection wording is gated on `firstPage`, exactly as the count
39+
is.** "No orders yet" is a claim about the collection, and page 2 of a scan has
40+
no standing to make one — page 1 had rows. It is reachable in a live store, not
41+
only in theory: the look-ahead read that minted the cursor and the read that
42+
follows it straddle a concurrent delete, and the second comes back empty. A
43+
non-first-page zero falls through to page-scoped wording (`Nothing on this
44+
page`) offering nothing to click, since nothing was filtered.
45+
46+
**`Clear filters` is one act with one builder — across the three screens this
47+
touches.** It was already the accessory on the active-filter summary section;
48+
that call site and the new zero-state button now come from the same builder, so
49+
the label, the verb and the payload cannot drift apart *on Orders, Pricing &
50+
inventory and Coupons*. `tax-page.ts:620` and `shipping-page.ts:1102` still
51+
hand-roll a byte-identical button of their own and are deliberately untouched
52+
here (one PR, one thing); they are the next-touch consolidation onto
53+
`clearFiltersButton`. The builder fires a BARE `apply-filter` — no values, so
54+
the scaffold rebuilds the level's default filter — and carries the drill path in
55+
the button's own `value`, which is what keeps the re-render on the level the
56+
operator was standing on rather than bouncing them to the root.
57+
58+
**A page with another page behind it never claims to be the end.** The pinned
59+
renderer short-circuits a zero-row table carrying `empty_text` to a bare `<p>`
60+
and takes the "Load more" button with it, so a mid-scan page narrowed to zero
61+
must keep a headers-only table and say the scan can continue. That was
62+
established for the low-stock filter and is now the shared rule for every
63+
screen, in one place, rather than something each list has to rediscover.
64+
65+
**It is built once.** `listResult` in `scaffold/list-detail.ts` decides which
66+
outcome a render is in; the three screens supply their own wording and nothing
67+
else. The list level's `render` now also receives `firstPage`, which is the one
68+
fact neither the honest-count rule nor the whole-collection gate can be derived
69+
without.
70+
71+
**Two containment fixes carried along.** A keyset cursor minted before the
72+
products filter grew its page-context wrapper used to arrive as a bare filter
73+
form and throw on the way into `render` — this screen's fail-closed banner in
74+
place of the page asked for. It now self-heals: the bare shape IS the form, so
75+
the operator's filter survives the deploy instead of being dropped, and an
76+
unreadable one degrades to "no filter set" rather than to an exception. And the
77+
cursor encoding is documented for what it actually carries: `fetchPage` and
78+
`render` share one filter object, so page context written during the fetch rides
79+
the next-page cursor too — which is fine while it is overwritten on every fetch,
80+
and a stale value on screen the moment it is merged instead.

packages/plugin/src/admin/coupons-page.ts

Lines changed: 57 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,18 @@ import {
3030
asRecord,
3131
backButton,
3232
carriedForm,
33+
clearFiltersButton,
3334
createListDetailHandler,
3435
customAction,
3536
decodePath,
36-
emptyState,
3737
encodePath,
3838
failClosedResponse,
3939
filterPanel,
4040
filterSummary,
4141
leafLevel,
42+
listIntroLine,
4243
listLevel,
44+
listResult,
4345
noticeBanner,
4446
PATH_FIELD,
4547
readAdminTokens,
@@ -372,13 +374,18 @@ function couponsListLevel() {
372374
});
373375
return { items: page.coupons, nextCursor: page.nextCursor };
374376
},
375-
render({ actions, path, filter, items, nextToken, notice, renderState }) {
376-
return couponsBlocks(actions, path, filter, items, nextToken, notice, renderState);
377+
render({ actions, path, filter, items, nextToken, firstPage, notice, renderState }) {
378+
return couponsBlocks(actions, path, filter, items, nextToken, firstPage, notice, renderState);
377379
},
378380
onError: () => couponsFailClosed(),
379381
});
380382
}
381383

384+
/** The standing half of the list's intro line — the row count goes in front of
385+
* it ({@link listIntroLine}). 78 chars; the longest count line this screen can
386+
* produce (`25 coupons on this page`) puts the whole line at 104 ≤ 140 (X-11). */
387+
const LIST_INTRO = "Search a coupon and open it. Discounts apply to the cart subtotal at checkout.";
388+
382389
function toClientFilter(form: CouponsFilterForm): CouponsListFilter {
383390
return form.search !== undefined ? { search: form.search } : {};
384391
}
@@ -407,23 +414,46 @@ function couponsBlocks(
407414
filter: CouponsFilterForm,
408415
coupons: CouponSummaryWire[],
409416
nextToken: string | undefined,
417+
firstPage: boolean,
410418
notice: Notice | undefined,
411419
renderState: CouponsRenderState | undefined,
412420
): Block[] {
413421
if (renderState?.kind === "new-coupon") return newCouponScreen(renderState.draft, notice);
422+
// ONE part for the screen's one authored filter field (L-3).
423+
const activeFilters = [filter.search !== undefined && `code: ${filter.search}`];
424+
const summary = filterSummary(activeFilters);
425+
const result = listResult({
426+
actions,
427+
path,
428+
count: coupons.length,
429+
filtered: summary !== undefined,
430+
firstPage,
431+
nextToken,
432+
noun: { one: "coupon", other: "coupons" },
433+
empty: {
434+
title: "No coupons yet",
435+
description: "Create one to start discounting carts.",
436+
blockId: "coupons:empty",
437+
// E-2's way IN: the SAME verb and the SAME words as the promoted button
438+
// above, because they are the same act and reach the same screen.
439+
actions: [{ type: "button", action_id: ACTION_NEW, label: "New coupon", value: {} }],
440+
},
441+
noMatch: {
442+
title: "No coupon matches that code",
443+
// The way IN is already on screen (the promoted "New coupon" button sits
444+
// above), so this state offers only the undo — one act per state.
445+
description: "Nothing came back for that search. Clear it to go back to every coupon.",
446+
blockId: "coupons:no-match",
447+
emptyText: "No coupon matches that code.",
448+
},
449+
});
414450
const blocks: Block[] = [
415451
{ type: "header", text: "Coupons", block_id: "coupons:hdr" },
416-
{
417-
type: "context",
418-
// 78 chars ≤ 140 (§1).
419-
text: "Search a coupon and open it. Discounts apply to the cart subtotal at checkout.",
420-
},
452+
listIntroLine(result.countLine, LIST_INTRO),
421453
createCouponButton(),
422454
];
423455
if (notice !== undefined) blocks.push(noticeBanner(notice));
424456

425-
// ONE part for the screen's one authored filter field (L-3).
426-
const activeFilters = [filter.search !== undefined && `code: ${filter.search}`];
427457
blocks.push(
428458
filterPanel({
429459
form: searchForm(actions, path, filter),
@@ -433,40 +463,25 @@ function couponsBlocks(
433463
// accordion (L-2).
434464
}),
435465
);
436-
const summary = filterSummary(activeFilters);
437466
if (summary !== undefined) {
438467
blocks.push({
439468
type: "section",
440469
text: summary,
441470
// The path rides in `value`, NOT `block_id` — a button echoes no
442471
// `block_id` (L-6, B-1).
443-
accessory: {
444-
type: "button",
445-
action_id: actions.applyFilter,
446-
label: "Clear filters",
447-
value: { [PATH_FIELD]: encodePath(path) },
448-
},
472+
accessory: clearFiltersButton(actions, path),
449473
block_id: "coupons:filter-summary",
450474
});
451475
}
452476

453-
const filtered = summary !== undefined;
454-
if (coupons.length === 0 && !filtered) {
455-
// E-2: the primary collection at its TRUE zero state. The table is
456-
// OMITTED and `empty` renders in its place, with the create affordance in
457-
// `empty.actions` — the SAME verb and the SAME words as the button above,
458-
// because they are the same act and now reach the same screen.
459-
blocks.push(
460-
emptyState({
461-
title: "No coupons yet",
462-
description: "Create one to start discounting carts.",
463-
size: "base",
464-
actions: [{ type: "button", action_id: ACTION_NEW, label: "New coupon", value: {} }],
465-
blockId: "coupons:empty",
466-
}),
467-
);
477+
// E-2: at zero the table is OMITTED and `empty` renders in its place. WHICH
478+
// state this is — no coupons at all, or a search that matched none with its own
479+
// undo attached — was decided once, in `listResult`.
480+
if (result.emptyBlock !== undefined) {
481+
blocks.push(result.emptyBlock);
468482
} else {
469-
blocks.push(couponsTable(coupons, nextToken));
483+
blocks.push(couponsTable(coupons, nextToken, result.emptyText));
484+
if (result.scanNote !== undefined) blocks.push(result.scanNote);
470485
}
471486
if (coupons.length > 0) blocks.push(openCouponForm(actions, path, coupons));
472487
return blocks;
@@ -541,7 +556,11 @@ function newCouponScreen(draft: CouponDraft | undefined, notice: Notice | undefi
541556
* per-value control the renderer does not have, and belongs to the
542557
* console-wide badge policy rather than to this screen alone.
543558
*/
544-
function couponsTable(coupons: CouponSummaryWire[], nextToken: string | undefined): TableBlock {
559+
function couponsTable(
560+
coupons: CouponSummaryWire[],
561+
nextToken: string | undefined,
562+
emptyText: string | undefined,
563+
): TableBlock {
545564
// ONE instant for the whole response, so no two rows of one table can be
546565
// judged against different clocks (see `couponStatus`).
547566
const now = new Date().toISOString();
@@ -569,7 +588,10 @@ function couponsTable(coupons: CouponSummaryWire[], nextToken: string | undefine
569588
})),
570589
page_action_id: COUPON_ACTIONS.page,
571590
...(nextToken !== undefined ? { next_cursor: nextToken } : {}),
572-
empty_text: "No coupon matches that code.",
591+
// OMITTED when another page remains behind a zero-row one: the renderer
592+
// short-circuits such a table to a bare `<p>` and takes `Load more` with it
593+
// (see `listResult`, outcome 3).
594+
...(emptyText !== undefined ? { empty_text: emptyText } : {}),
573595
};
574596
}
575597

0 commit comments

Comments
 (0)