@stapel/listings-react 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/MODULE.md +12 -6
  3. package/README.md +33 -17
  4. package/dist/api/generated/schema.d.ts +127 -1
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/listingsApi.d.ts +35 -22
  7. package/dist/api/listingsApi.d.ts.map +1 -1
  8. package/dist/api/listingsApi.js +17 -0
  9. package/dist/api/listingsApi.js.map +1 -1
  10. package/dist/api/types.d.ts +43 -2
  11. package/dist/api/types.d.ts.map +1 -1
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/MyListingsPane.d.ts +24 -11
  14. package/dist/default/MyListingsPane.d.ts.map +1 -1
  15. package/dist/default/MyListingsPane.js +26 -10
  16. package/dist/default/MyListingsPane.js.map +1 -1
  17. package/dist/headless/MyListings.d.ts +27 -12
  18. package/dist/headless/MyListings.d.ts.map +1 -1
  19. package/dist/headless/MyListings.js +29 -7
  20. package/dist/headless/MyListings.js.map +1 -1
  21. package/dist/i18n/es.d.ts.map +1 -1
  22. package/dist/i18n/es.js +7 -2
  23. package/dist/i18n/es.js.map +1 -1
  24. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  25. package/dist/i18n/generated/errors.es.gen.js +1 -1
  26. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  27. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  28. package/dist/i18n/generated/errors.gen.js +3 -0
  29. package/dist/i18n/generated/errors.gen.js.map +1 -1
  30. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  31. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  32. package/dist/i18n/keys.d.ts +9 -2
  33. package/dist/i18n/keys.d.ts.map +1 -1
  34. package/dist/i18n/keys.js +14 -3
  35. package/dist/i18n/keys.js.map +1 -1
  36. package/dist/i18n/ru.d.ts.map +1 -1
  37. package/dist/i18n/ru.js +7 -2
  38. package/dist/i18n/ru.js.map +1 -1
  39. package/dist/index.d.ts +8 -6
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +8 -6
  42. package/dist/index.js.map +1 -1
  43. package/dist/model/mine.d.ts +44 -0
  44. package/dist/model/mine.d.ts.map +1 -0
  45. package/dist/model/mine.js +52 -0
  46. package/dist/model/mine.js.map +1 -0
  47. package/dist/model/mineSource.d.ts +39 -34
  48. package/dist/model/mineSource.d.ts.map +1 -1
  49. package/dist/model/mineSource.js +10 -41
  50. package/dist/model/mineSource.js.map +1 -1
  51. package/dist/model/mutations.d.ts.map +1 -1
  52. package/dist/model/mutations.js +11 -4
  53. package/dist/model/mutations.js.map +1 -1
  54. package/dist/model/queryKeys.d.ts +7 -2
  55. package/dist/model/queryKeys.d.ts.map +1 -1
  56. package/dist/model/queryKeys.js +2 -0
  57. package/dist/model/queryKeys.js.map +1 -1
  58. package/dist/model/status.d.ts +12 -31
  59. package/dist/model/status.d.ts.map +1 -1
  60. package/dist/model/status.js +44 -0
  61. package/dist/model/status.js.map +1 -1
  62. package/llms.txt +6 -4
  63. package/manifest.json +38 -6
  64. package/nav-manifest.json +1 -1
  65. package/package.json +1 -1
  66. package/src/analytics/generated/events.json +1 -1
  67. package/src/api/generated/schema.ts +127 -1
  68. package/src/api/listingsApi.ts +60 -21
  69. package/src/api/types.ts +46 -2
  70. package/src/default/MyListingsPane.tsx +98 -34
  71. package/src/headless/MyListings.tsx +62 -22
  72. package/src/i18n/es.ts +10 -3
  73. package/src/i18n/generated/errors.es.gen.ts +1 -1
  74. package/src/i18n/generated/errors.gen.ts +3 -0
  75. package/src/i18n/generated/errors.json +9 -0
  76. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  77. package/src/i18n/keys.ts +16 -4
  78. package/src/i18n/ru.ts +10 -3
  79. package/src/index.ts +16 -5
  80. package/src/model/mine.ts +70 -0
  81. package/src/model/mineSource.ts +46 -41
  82. package/src/model/mutations.ts +11 -4
  83. package/src/model/queryKeys.ts +9 -2
  84. package/src/model/status.ts +15 -0
package/src/i18n/keys.ts CHANGED
@@ -144,8 +144,15 @@ export const LISTINGS_I18N_KEYS = {
144
144
  mineEmpty: "listings.mine.empty",
145
145
  mineRetry: "listings.mine.retry",
146
146
  mineCountersFailed: "listings.mine.counters_failed",
147
- /** The upstream gap, named on screen see `headless/MyListings.tsx`. */
148
- mineSourceMissing: "listings.mine.source_missing",
147
+ /** Per-tab empties: "no drafts" and "nothing sold yet" are different
148
+ * sentences, and one generic "nothing here" says neither. */
149
+ mineEmptyActive: "listings.mine.empty.active",
150
+ mineEmptyDrafts: "listings.mine.empty.drafts",
151
+ mineEmptyArchived: "listings.mine.empty.archived",
152
+ /** The takedowns — the rows no tab folds in (`my/counters` counts them in
153
+ * none), shown outside the tabs so they cannot be missed. */
154
+ mineBlockedTitle: "listings.mine.blocked.title",
155
+ mineBlockedLoadFailed: "listings.mine.blocked.load_failed",
149
156
  mineLiveUnderReview: "listings.mine.live_under_review",
150
157
  mineEdit: "listings.mine.edit",
151
158
  mineArchive: "listings.mine.archive",
@@ -313,8 +320,13 @@ export const listingsI18nBundleEn: Record<string, string> = {
313
320
  "listings.mine.empty": "Nothing here yet",
314
321
  "listings.mine.retry": "Try again",
315
322
  "listings.mine.counters_failed": "We could not count your listings",
316
- "listings.mine.source_missing":
317
- "This app cannot list your own listings yet: the listings service has no owner-scoped list endpoint. Your counts below are real.",
323
+ "listings.mine.empty.active": "Nothing of yours is live or awaiting review",
324
+ "listings.mine.empty.drafts": "No drafts anything you start appears here",
325
+ "listings.mine.empty.archived": "Nothing archived, paused, expired or sold yet",
326
+ "listings.mine.blocked.title":
327
+ "{count} of your listings were taken down by moderation",
328
+ "listings.mine.blocked.load_failed":
329
+ "We could not check whether any of your listings were taken down",
318
330
  "listings.mine.live_under_review":
319
331
  "Published, changes under review",
320
332
  "listings.mine.edit": "Edit",
package/src/i18n/ru.ts CHANGED
@@ -37,12 +37,14 @@ export { listingsErrorBundleRu } from "./generated/errors.ru.gen.js";
37
37
  export const listingsI18nBundleRu: I18nDictionary = {
38
38
  ...listingsErrorBundleRu,
39
39
 
40
- // ── the 9 stapel_listings-owned codes, pair-authored ─────────────────────
40
+ // ── the 10 stapel_listings-owned codes, pair-authored ────────────────────
41
41
  "error.400.category_required": "Нужно выбрать категорию",
42
42
  "error.400.image_required":
43
43
  "Чтобы опубликовать объявление, добавьте хотя бы одно фото",
44
44
  "error.400.listing_feature_not_allowed":
45
45
  "Характеристика «{feature}» не относится к этой категории",
46
+ "error.400.listing_invalid_status_filter":
47
+ "Неизвестный статус объявления: «{status}»",
46
48
  "error.400.publish_validation_failed":
47
49
  "Объявление не прошло проверку и не опубликовано",
48
50
  "error.403.listing_not_owner": "Это не ваше объявление",
@@ -177,8 +179,13 @@ export const listingsI18nBundleRu: I18nDictionary = {
177
179
  "listings.mine.empty": "Здесь пока пусто",
178
180
  "listings.mine.retry": "Повторить",
179
181
  "listings.mine.counters_failed": "Не удалось посчитать ваши объявления",
180
- "listings.mine.source_missing":
181
- "Приложение пока не может перечислить ваши объявления: у сервиса объявлений нет списка, ограниченного владельцем. Счётчики ниже — настоящие.",
182
+ "listings.mine.empty.active": "Ни одно ваше объявление не опубликовано и не ждёт проверки",
183
+ "listings.mine.empty.drafts": "Черновиков нет всё начатое появится здесь",
184
+ "listings.mine.empty.archived": "Пока ничего не в архиве, не снято, не истекло и не продано",
185
+ "listings.mine.blocked.title":
186
+ "Модерация сняла ваших объявлений: {count}",
187
+ "listings.mine.blocked.load_failed":
188
+ "Не удалось проверить, снимала ли модерация ваши объявления",
182
189
  "listings.mine.live_under_review": "Опубликовано, правки на проверке",
183
190
  "listings.mine.edit": "Редактировать",
184
191
  "listings.mine.archive": "В архив",
package/src/index.ts CHANGED
@@ -38,9 +38,10 @@
38
38
  * a grey button and never a hidden one.
39
39
  * 4. **An absence is never rendered as a zero.** A soft-deleted listing says
40
40
  * it was removed rather than 404-ing like a typo; a stored feature this
41
- * build cannot read is counted rather than dropped; and the owner's own
42
- * listings which stapel-listings 0.6.1 has NO endpoint forarrive as a
43
- * named failure rather than an empty grid (`headless/MyListings.tsx`).
41
+ * build cannot read is counted rather than dropped; an empty dashboard tab
42
+ * says WHICH emptiness it is; and a moderation takedownthe one status
43
+ * `my/counters` counts in no tab at all — is shown outside the tabs rather
44
+ * than falling out of the screen (`headless/MyListings.tsx`).
44
45
  *
45
46
  * ── The seams, and why they are not imports ────────────────────────────────
46
47
  *
@@ -88,7 +89,10 @@ export type {
88
89
  ListingPageParams,
89
90
  ListingStatusInfo,
90
91
  MyCounters,
92
+ MyListingCard,
93
+ MyListingsParams,
91
94
  PaginatedListingCards,
95
+ PaginatedMyListingCards,
92
96
  PublishResponse,
93
97
  Schemas,
94
98
  } from "./api/types.js";
@@ -97,6 +101,7 @@ export type {
97
101
  export {
98
102
  MY_LISTINGS_TABS,
99
103
  MY_LISTINGS_TAB_STATUSES,
104
+ MY_LISTINGS_UNTABBED_STATUSES,
100
105
  isPubliclyVisible,
101
106
  lifecycleCaption,
102
107
  listingStatusView,
@@ -112,9 +117,15 @@ export type {
112
117
  } from "./model/status.js";
113
118
  export { LISTING_TRANSITIONS, canDelete, canTransition } from "./model/transitions.js";
114
119
 
115
- // ── model: the owner-list gap, named ─────────────────────────────────────────
116
- export { MY_LISTINGS_SOURCE_MISSING } from "./model/mineSource.js";
120
+ // ── model: the owner's own rows ──────────────────────────────────────────────
121
+ export { defaultMyListingsSource } from "./model/mineSource.js";
117
122
  export type { MyListingsSource } from "./model/mineSource.js";
123
+ export {
124
+ myListingImages,
125
+ myListingPrice,
126
+ myListingTitle,
127
+ showsDraft,
128
+ } from "./model/mine.js";
118
129
 
119
130
  // ── model: the draft twin ────────────────────────────────────────────────────
120
131
  export {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What an owner's row SAYS, when half its fields may still be a draft.
3
+ *
4
+ * A listing carries every user-editable value twice: the `*_draft` twin the
5
+ * owner is writing, and the published field `publish` promotes it onto. The
6
+ * public card reads the published half — correct for a shop window, and empty
7
+ * for a listing that has never been published. A drafts tab built on it is a
8
+ * column of blank rows.
9
+ *
10
+ * So the rule, in one place rather than at three call sites: **show the
11
+ * published value when there is one, and the draft otherwise.** Not "always
12
+ * the draft" — a live listing being edited must go on reading as what
13
+ * strangers currently see, or the seller cannot tell what is on the shelf
14
+ * from what they are about to put there.
15
+ *
16
+ * Pure, so `test/mine.test.tsx` asserts the table directly.
17
+ */
18
+ import type { MyListingCard } from "../api/types.js";
19
+
20
+ /** Is this string field's published half absent? `""` counts: the model
21
+ * declares `title = CharField(blank=True, default="")`, so an unpublished
22
+ * listing carries the empty string, not `null`. */
23
+ function empty(value: string | null | undefined): boolean {
24
+ return value === null || value === undefined || value === "";
25
+ }
26
+
27
+ /**
28
+ * The row's heading: the published title, the draft title, or `undefined`.
29
+ *
30
+ * `undefined` and not the id: a caller that wants "Listing #41" as a last
31
+ * resort can say so, and one that wants to render a placeholder can do that
32
+ * instead. Inventing the fallback here would take the choice away.
33
+ */
34
+ export function myListingTitle(row: MyListingCard): string | undefined {
35
+ if (!empty(row.title)) return row.title as string;
36
+ if (!empty(row.title_draft)) return row.title_draft as string;
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * The row's price, published half first. A decimal STRING on the wire
42
+ * (`DecimalField` → `"200.00"`), passed through untouched — formatting it is
43
+ * a skin's job and rounding it here would lose the trailing zero a currency
44
+ * needs.
45
+ */
46
+ export function myListingPrice(row: MyListingCard): string | undefined {
47
+ if (!empty(row.price)) return row.price as string;
48
+ if (!empty(row.price_draft)) return row.price_draft as string;
49
+ return undefined;
50
+ }
51
+
52
+ /** The row's images, published half first — `[]` when neither has any. */
53
+ export function myListingImages(row: MyListingCard): readonly string[] {
54
+ const published = row.images;
55
+ if (published !== null && published !== undefined && published.length > 0) {
56
+ return published;
57
+ }
58
+ return row.images_draft ?? [];
59
+ }
60
+
61
+ /**
62
+ * Is what this row shows the DRAFT rather than what strangers see?
63
+ *
64
+ * The half of the rule a person is owed on screen: a row whose title came off
65
+ * the draft twin is showing something nobody else can read yet, and a skin
66
+ * that did not mark it would be quietly claiming otherwise.
67
+ */
68
+ export function showsDraft(row: MyListingCard): boolean {
69
+ return empty(row.title) && !empty(row.title_draft);
70
+ }
@@ -1,55 +1,60 @@
1
1
  /**
2
- * THE GAP: stapel-listings cannot list a person their own listings.
2
+ * THE GAP, AND THE ROUTE THAT CLOSED IT.
3
3
  *
4
- * This is the one place in the pair where a contract is missing rather than
5
- * awkward, so it gets its own module and its own name.
4
+ * This file used to open "stapel-listings cannot list a person their own
5
+ * listings", and that was the only place in the pair where a contract was
6
+ * missing rather than awkward. `GET /listings/` answers `qs.published()` and
7
+ * takes no owner parameter (`views.ListingViewSet.get_queryset`): it returns
8
+ * the whole marketplace's shop window and can be narrowed to nobody. The only
9
+ * owner-scoped reads were `my/counters` (three integers) and `my/favorites`,
10
+ * so a seller's own DRAFTS were unreachable by any call the contract offered
11
+ * — and drafts are not indexed either, so routing around it through
12
+ * `@stapel/search-react`'s `owner=` would have returned the published subset
13
+ * and quietly called it "everything". The dashboard shipped with a
14
+ * host-injected source and NAMED the absence on screen, because "we cannot
15
+ * ask" and "you have no listings" are different sentences.
6
16
  *
7
- * `GET /listings/` answers `qs.published()` and takes no owner parameter
8
- * (`views.ListingViewSet.get_queryset`): it returns the whole marketplace's
9
- * shop window and can be narrowed to nobody. The only owner-scoped reads in
10
- * the module are `my/counters` (three integers) and `my/favorites`. So a
11
- * seller's own DRAFTS are unreachable by any call this contract offers and
12
- * drafts are not indexed either, so routing around it through
13
- * `@stapel/search-react`'s `owner=` would return the published subset and
14
- * quietly call it "everything".
17
+ * **stapel-listings 0.7.0 answers it**: `GET /listings/my/listings/`, the
18
+ * caller's own rows in every status, `?status=` for a tab's set, the same
19
+ * `IDAnchorPagination` envelope the other two owner reads use. So
20
+ * {@link defaultMyListingsSource} is what the dashboard runs on now, and
21
+ * there is no failure state left to name the missing-source error and its
22
+ * i18n key are gone rather than kept as a comment about a thing that no
23
+ * longer happens.
15
24
  *
16
- * The storefront spec assumed `GET /listings/` was "for mine" (§4.1). It is
17
- * not, and this pair records the gap rather than papering over it.
18
- *
19
- * Two upstream asks, in preference order:
20
- * 1. an `?owner=me` filter (or a `my/listings` action) on the list endpoint,
21
- * with the same `IDAnchorPagination` envelope the other two use;
22
- * 2. failing that, a `status` filter on it, so at least the published subset
23
- * can be narrowed without a search module.
24
- *
25
- * Until then a host injects a {@link MyListingsSource} and the dashboard
26
- * works completely; without one it shows the real counters and NAMES the
27
- * absence, because "we cannot ask" and "you have no listings" are different
28
- * sentences.
25
+ * {@link MyListingsSource} stays, as a seam and not as a workaround: a
26
+ * deployment that keeps its sellers' rows somewhere else (a read model, a
27
+ * legacy table, a host that has already fetched the page) hands one in and
28
+ * the dashboard renders it unchanged. What it must return is one page of the
29
+ * owner card including the `moderation_status` and the `*_draft` twins,
30
+ * which is why the type is {@link PaginatedMyListingCards} and not the public
31
+ * card envelope.
29
32
  */
30
- import { StapelApiError } from "@stapel/core";
31
- import type { ListingPageParams, PaginatedListingCards } from "../api/types.js";
33
+ import type { ListingsApi } from "../api/listingsApi.js";
34
+ import type { MyListingsParams, PaginatedMyListingCards } from "../api/types.js";
32
35
  import type { MyListingsTab } from "./status.js";
33
- import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
36
+ import { MY_LISTINGS_TAB_STATUSES } from "./status.js";
34
37
 
35
- /** One page of the caller's own listings, however the host can get them. */
38
+ /** One page of the caller's own listings, however the host gets them. */
36
39
  export type MyListingsSource = (args: {
37
40
  readonly tab: MyListingsTab;
38
- readonly page: ListingPageParams;
41
+ readonly page: MyListingsParams;
39
42
  readonly signal?: AbortSignal;
40
- }) => Promise<PaginatedListingCards>;
43
+ }) => Promise<PaginatedMyListingCards>;
41
44
 
42
45
  /**
43
- * The error a dashboard without a source reports.
46
+ * The source the dashboard uses unless a host replaces it: the contract's own
47
+ * route, narrowed to the tab's statuses.
44
48
  *
45
- * `status: 0` on purpose: this is a fact about the deployment's wiring, not
46
- * something a server said, and a client-side refusal must never be
47
- * indistinguishable from one that came over the wire.
49
+ * The tab statuses table is `MY_LISTINGS_TAB_STATUSES`, which is a copy of
50
+ * the SERVER's grouping in `views.my_counters`. That is the whole point: the
51
+ * rows this fetches and the count on the tab beside them are the same set,
52
+ * asserted upstream (`tests/test_my_listings.py`) and here.
48
53
  */
49
- export const MY_LISTINGS_SOURCE_MISSING: StapelApiError = new StapelApiError({
50
- code: LISTINGS_I18N_KEYS.mineSourceMissing,
51
- message:
52
- "No MyListingsSource was supplied, and stapel-listings has no " +
53
- "owner-scoped list endpoint to fall back on.",
54
- status: 0,
55
- });
54
+ export function defaultMyListingsSource(api: ListingsApi): MyListingsSource {
55
+ return ({ tab, page, signal }) =>
56
+ api.myListings(
57
+ { ...page, status: MY_LISTINGS_TAB_STATUSES[tab] },
58
+ signal !== undefined ? { signal } : {}
59
+ );
60
+ }
@@ -27,10 +27,13 @@ import { listingsQueryKeys } from "./queryKeys.js";
27
27
  * ── The invalidation rule ──────────────────────────────────────────────────
28
28
  *
29
29
  * A write that can move `status` or `moderation_status` invalidates the
30
- * DETAIL, the STATUS probe and the COUNTERS — the counters because their
31
- * three tabs are defined by status sets (`views.my_counters`), so a publish
32
- * silently moves a row from "drafts" to "active" and a dashboard that did not
33
- * refetch would show the old number beside the new row.
30
+ * DETAIL, the STATUS probe, the COUNTERS and the owner's ROWS — the counters
31
+ * because their three tabs are defined by status sets (`views.my_counters`),
32
+ * so a publish silently moves a row from "drafts" to "active" and a dashboard
33
+ * that did not refetch would show the old number beside the new row; the rows
34
+ * for the same reason, since stapel-listings 0.7.0 gave them a route and they
35
+ * are narrowed by exactly those status sets. Invalidating one and not the
36
+ * other is the shape of the bug where the badge says 2 and the tab shows 3.
34
37
  *
35
38
  * Nothing here is optimistic except the favourite toggle, and that exception
36
39
  * is argued at its own hook.
@@ -46,6 +49,7 @@ function invalidateListing(queryClient: QueryClient, id: number): void {
46
49
  void queryClient.invalidateQueries({
47
50
  queryKey: listingsQueryKeys.validateDraft(id),
48
51
  });
52
+ void queryClient.invalidateQueries({ queryKey: listingsQueryKeys.allMine() });
49
53
  }
50
54
 
51
55
  /** Start a draft. The server forces `owner` and `status=draft`. */
@@ -63,6 +67,9 @@ export function useCreateDraft(): UseMutationResult<
63
67
  void queryClient.invalidateQueries({
64
68
  queryKey: listingsQueryKeys.myCounters(),
65
69
  });
70
+ void queryClient.invalidateQueries({
71
+ queryKey: listingsQueryKeys.allMine(),
72
+ });
66
73
  },
67
74
  });
68
75
  }
@@ -49,12 +49,17 @@ export const listingsQueryKeys: {
49
49
  myFavorites(
50
50
  page: ListingPageKey
51
51
  ): readonly ["listings", "my", "favorites", ListingPageKey];
52
- /** The owner's own rows, per tab see `headless/MyListings.tsx` for why
53
- * this read goes through an injected source. */
52
+ /** The owner's own rows, per tab (`GET my/listings/?status=…`). */
54
53
  mine(
55
54
  tab: string,
56
55
  page: ListingPageKey
57
56
  ): readonly ["listings", "my", "listings", string, ListingPageKey];
57
+ /** The owner's rows no tab folds in — a takedown. A sibling of `mine` and
58
+ * not one of its tabs, because it answers a different question and must not
59
+ * be evicted when a tab pages. */
60
+ mineUntabbed(): readonly ["listings", "my", "listings", "untabbed"];
61
+ /** Every owner-row page, for an invalidation after a write. */
62
+ allMine(): readonly ["listings", "my", "listings"];
58
63
  /** `GET /{pk}/validate-draft/` — the dry run of a publish. */
59
64
  validateDraft(id: number): readonly ["listings", "validate-draft", number];
60
65
  /** Every favourites PAGE, for an invalidation after a toggle — the cursor
@@ -70,6 +75,8 @@ export const listingsQueryKeys: {
70
75
  myCounters: () => [ROOT, "my", "counters"],
71
76
  myFavorites: (page) => [ROOT, "my", "favorites", page],
72
77
  mine: (tab, page) => [ROOT, "my", "listings", tab, page],
78
+ mineUntabbed: () => [ROOT, "my", "listings", "untabbed"],
79
+ allMine: () => [ROOT, "my", "listings"],
73
80
  validateDraft: (id) => [ROOT, "validate-draft", id],
74
81
  allFavorites: () => [ROOT, "my", "favorites"],
75
82
  allLists: () => [ROOT, "list"],
@@ -29,6 +29,7 @@
29
29
  * asserts the whole 9 × 4 table, so a state added upstream is a red test and
30
30
  * not a bare enum value on a page.
31
31
  */
32
+ import { LISTING_STATUSES } from "../api/types.js";
32
33
  import type {
33
34
  ListingLifecycleStatus,
34
35
  ListingModerationStatus,
@@ -277,3 +278,17 @@ export function tabOf(
277
278
  }
278
279
  return undefined;
279
280
  }
281
+
282
+ /**
283
+ * The statuses no tab folds in — `blocked`, and nothing else today.
284
+ *
285
+ * DERIVED, not written down a second time: a status added upstream and left
286
+ * out of the counter groupings lands here automatically and gets shown, which
287
+ * is the opposite of what a hardcoded `["blocked"]` would do the day it goes
288
+ * stale. The dashboard renders these rows OUTSIDE the tabs (see
289
+ * `default/MyListingsPane.tsx`) rather than folding them into one, because a
290
+ * tab's rows and its `my/counters` badge have to describe the same set — and
291
+ * `my/counters` counts a takedown in no tab at all.
292
+ */
293
+ export const MY_LISTINGS_UNTABBED_STATUSES: readonly ListingLifecycleStatus[] =
294
+ LISTING_STATUSES.filter((status) => tabOf(status) === undefined);