@stapel/listings-react 0.23.0 → 0.25.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.
- package/CHANGELOG.md +93 -0
- package/MODULE.md +4 -0
- package/README.md +75 -0
- package/dist/api/generated/schema.d.ts +126 -11
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +5 -3
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +51 -4
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/CardBadges.d.ts +47 -0
- package/dist/default/CardBadges.d.ts.map +1 -0
- package/dist/default/CardBadges.js +38 -0
- package/dist/default/CardBadges.js.map +1 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +6 -6
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +12 -19
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +12 -8
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +3 -5
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/ListingSpecList.d.ts +74 -0
- package/dist/default/ListingSpecList.d.ts.map +1 -0
- package/dist/default/ListingSpecList.js +90 -0
- package/dist/default/ListingSpecList.js.map +1 -0
- package/dist/default/cardGallery.d.ts +81 -0
- package/dist/default/cardGallery.d.ts.map +1 -0
- package/dist/default/cardGallery.js +266 -0
- package/dist/default/cardGallery.js.map +1 -0
- package/dist/default/index.d.ts +6 -0
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +5 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +20 -2
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +19 -4
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +134 -0
- package/dist/model/cardBadges.d.ts.map +1 -0
- package/dist/model/cardBadges.js +131 -0
- package/dist/model/cardBadges.js.map +1 -0
- package/dist/model/draft.d.ts +10 -2
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +22 -4
- package/dist/model/draft.js.map +1 -1
- package/dist/model/featureText.d.ts +85 -0
- package/dist/model/featureText.d.ts.map +1 -0
- package/dist/model/featureText.js +153 -0
- package/dist/model/featureText.js.map +1 -0
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +59 -0
- package/dist/model/features.js.map +1 -1
- package/llms.txt +5 -2
- package/manifest.json +47 -1
- package/nav-manifest.json +1 -1
- package/package.json +10 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +126 -11
- package/src/api/listingsApi.ts +5 -3
- package/src/api/types.ts +60 -6
- package/src/default/CardBadges.tsx +105 -0
- package/src/default/ListingCard.tsx +7 -21
- package/src/default/ListingDetailPane.tsx +13 -37
- package/src/default/ListingPhoto.tsx +54 -26
- package/src/default/ListingSerpCard.tsx +10 -28
- package/src/default/ListingSpecList.tsx +195 -0
- package/src/default/cardGallery.ts +287 -0
- package/src/default/index.ts +22 -0
- package/src/headless/ListingComposer.tsx +39 -5
- package/src/i18n/es.ts +3 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +15 -0
- package/src/model/cardBadges.ts +237 -0
- package/src/model/draft.ts +23 -5
- package/src/model/featureText.ts +251 -0
- package/src/model/features.ts +60 -0
package/src/index.ts
CHANGED
|
@@ -166,6 +166,21 @@ export {
|
|
|
166
166
|
unreadableFeatureCount,
|
|
167
167
|
} from "./model/features.js";
|
|
168
168
|
export type { FeatureCopySource } from "./model/features.js";
|
|
169
|
+
export { featureUnit, formatSpecValue } from "./model/featureText.js";
|
|
170
|
+
|
|
171
|
+
// ── model: the card badge contract (stapel-listings 0.21.3) ──────────────────
|
|
172
|
+
export {
|
|
173
|
+
badgePresentation,
|
|
174
|
+
badgeValueText,
|
|
175
|
+
cardBadgeText,
|
|
176
|
+
cardBadgeTexts,
|
|
177
|
+
hasCardBadgeContract,
|
|
178
|
+
} from "./model/cardBadges.js";
|
|
179
|
+
export type {
|
|
180
|
+
CardBadgeContract,
|
|
181
|
+
CardBadgePresentation,
|
|
182
|
+
CardBadgeRow,
|
|
183
|
+
} from "./model/cardBadges.js";
|
|
169
184
|
|
|
170
185
|
// ── model: the engagement axis (already-seen / view count / the overlay) ─────
|
|
171
186
|
export {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CARD BADGE CONTRACT — what a badge on a result card is allowed to say.
|
|
3
|
+
*
|
|
4
|
+
* ── What was on screen ────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A live card's badge line read "Brick · 3 · 9". Three true facts about a
|
|
7
|
+
* flat, and two of them unreadable: 3 what, 9 what. The projection carried
|
|
8
|
+
* the VALUES and the display half printed them, because that is all a stored
|
|
9
|
+
* `features_badges` row could offer — a number with no unit and no question
|
|
10
|
+
* beside it.
|
|
11
|
+
*
|
|
12
|
+
* stapel-listings 0.21.3 adds the missing half to each element:
|
|
13
|
+
*
|
|
14
|
+
* `value` the raw answer
|
|
15
|
+
* `label` its resolved copy, where the answer is an option
|
|
16
|
+
* `unit` what the number is measured in
|
|
17
|
+
* `name` the question — the feature's own display name
|
|
18
|
+
* `presentation` which of those to print, decided by the SERVER
|
|
19
|
+
*
|
|
20
|
+
* `presentation` is the load-bearing key, and it is the server's decision
|
|
21
|
+
* rather than a rule guessed here, because the right badge for a value
|
|
22
|
+
* depends on the CATEGORY: "3 rooms" wants its name, "Brick" is a
|
|
23
|
+
* boolean whose name IS the badge, "20 000 km" wants its unit and no name at
|
|
24
|
+
* all, and no client-side heuristic gets all three right for every catalogue
|
|
25
|
+
* in the fleet.
|
|
26
|
+
*
|
|
27
|
+
* ── The four presentations ───────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* `value` the value alone — "Brick" (an option label)
|
|
30
|
+
* `value_unit` value and unit — "20 000 km"
|
|
31
|
+
* `name_value` name and value — "Floor 3": a SPACE and never a colon,
|
|
32
|
+
* because a card is a caption and not a
|
|
33
|
+
* form. The unit is kept when the
|
|
34
|
+
* element carries one — a named row
|
|
35
|
+
* that dropped it reads "Mileage 20 000"
|
|
36
|
+
* `name` the name alone — a TRUE boolean, whose whole content
|
|
37
|
+
* is that the flat is brick. A FALSE
|
|
38
|
+
* one renders nothing: "not brick" is
|
|
39
|
+
* not a selling point, and a card is a
|
|
40
|
+
* summary rather than a form.
|
|
41
|
+
*
|
|
42
|
+
* ── Why a local type extension and not a regenerated schema ───────────────
|
|
43
|
+
*
|
|
44
|
+
* The generated `FeatureDao` union is already unusable on this side (the
|
|
45
|
+
* discriminator defect, `api/types.ts`), `features_badges` is a `JSONField`
|
|
46
|
+
* on the serializer, and this pair reads the row through
|
|
47
|
+
* {@link ListingFeatureDao} — a hand-written mirror of the runtime shape —
|
|
48
|
+
* for exactly that reason. The contract is an additive extension of the same
|
|
49
|
+
* mirror, so a regeneration cannot take it away and an older backend that
|
|
50
|
+
* sends none of the five keys is simply a row where
|
|
51
|
+
* {@link hasCardBadgeContract} is false.
|
|
52
|
+
*
|
|
53
|
+
* ── The fallback is the whole compatibility story ─────────────────────────
|
|
54
|
+
*
|
|
55
|
+
* A row with no `presentation` is a row from a server that predates the
|
|
56
|
+
* contract, and it is rendered exactly as it is rendered today — through
|
|
57
|
+
* `@stapel/attributes-react`'s `<FeatureBadges>`, off the stored DAO's own
|
|
58
|
+
* config. Nothing about this module is required for a card to draw.
|
|
59
|
+
*/
|
|
60
|
+
import type { ListingCardBadgeElement, ListingFeatureDao } from "../api/types.js";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Which parts of a badge element the server asked to be printed.
|
|
64
|
+
*
|
|
65
|
+
* Read off the GENERATED element rather than spelled again here: the four
|
|
66
|
+
* readings are the server's enum, and a fifth added upstream must turn this
|
|
67
|
+
* build red instead of falling through {@link badgePresentation}'s default in
|
|
68
|
+
* silence.
|
|
69
|
+
*/
|
|
70
|
+
export type CardBadgePresentation = ListingCardBadgeElement["presentation"];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The keys stapel-listings 0.21.3 adds to a `features_badges` /
|
|
74
|
+
* `features_title` element.
|
|
75
|
+
*
|
|
76
|
+
* The NAMES and their types come from the generated element; the optionality
|
|
77
|
+
* is this pair's own, and is the whole compatibility story — the contract
|
|
78
|
+
* makes them required, and a server that predates it sends none of them. Same
|
|
79
|
+
* argument as {@link ListingEngagementFields}, one file over: a generated type
|
|
80
|
+
* is a promise about the contract, not about the bytes a deployment sends.
|
|
81
|
+
*/
|
|
82
|
+
export type CardBadgeContract = {
|
|
83
|
+
/** The raw answer. Absent on a redacted row, which is why the renderers
|
|
84
|
+
* below all tolerate it, and `unknown` because the DAO union types it per
|
|
85
|
+
* feature type and a badge prints whatever it was given. */
|
|
86
|
+
readonly value?: unknown;
|
|
87
|
+
} & {
|
|
88
|
+
readonly [K in "label" | "unit" | "name"]?: ListingCardBadgeElement[K] | null;
|
|
89
|
+
} & {
|
|
90
|
+
/** Widened back to `string` on purpose: {@link badgePresentation} exists to
|
|
91
|
+
* REFUSE a reading it does not know, and a field typed as the four would
|
|
92
|
+
* make that refusal unreachable — and untestable — while a server is still
|
|
93
|
+
* free to send a fifth. */
|
|
94
|
+
readonly presentation?: string | null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** A stored badge row, with the contract's keys where the server sends them. */
|
|
98
|
+
export type CardBadgeRow = ListingFeatureDao & CardBadgeContract;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `presentation` as one of the four, or `undefined`.
|
|
102
|
+
*
|
|
103
|
+
* An unrecognised spelling is `undefined` rather than a guess: the whole
|
|
104
|
+
* point of the key is that the SERVER decided, and a client that invented a
|
|
105
|
+
* fifth reading would print one category's rule on another category's card.
|
|
106
|
+
*/
|
|
107
|
+
export function badgePresentation(row: CardBadgeRow): CardBadgePresentation | undefined {
|
|
108
|
+
const raw = row.presentation;
|
|
109
|
+
if (typeof raw !== "string") return undefined;
|
|
110
|
+
switch (raw) {
|
|
111
|
+
case "value":
|
|
112
|
+
case "value_unit":
|
|
113
|
+
case "name_value":
|
|
114
|
+
case "name":
|
|
115
|
+
return raw;
|
|
116
|
+
default:
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Does this projection speak the contract at all?
|
|
123
|
+
*
|
|
124
|
+
* ONE row declaring a `presentation` is enough: the server writes the whole
|
|
125
|
+
* list in one pass, so a mixed list means a row the server deliberately left
|
|
126
|
+
* plain, and that row still renders (as its value alone) rather than
|
|
127
|
+
* dragging the other four back to the old path.
|
|
128
|
+
*/
|
|
129
|
+
export function hasCardBadgeContract(rows: readonly CardBadgeRow[]): boolean {
|
|
130
|
+
return rows.some((row) => badgePresentation(row) !== undefined);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Trimmed copy, or `""` — `null` is the shape the wire uses for absent. */
|
|
134
|
+
function text(value: string | null | undefined): string {
|
|
135
|
+
return typeof value === "string" ? value.trim() : "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* How many decimals the wire actually stated.
|
|
140
|
+
*
|
|
141
|
+
* A card badge must not invent precision and must not lose it: the server
|
|
142
|
+
* sends "2.0" for an engine volume and `2` for a number of rooms, and JS
|
|
143
|
+
* cannot tell `2.0` from `2` once it is a number — so a STRING value keeps
|
|
144
|
+
* the decimals it was written with and a number keeps its own.
|
|
145
|
+
*/
|
|
146
|
+
function statedDecimals(raw: unknown): number {
|
|
147
|
+
const written = typeof raw === "string" ? raw : String(raw);
|
|
148
|
+
const tail = written.split(".")[1];
|
|
149
|
+
return tail === undefined ? 0 : tail.replace(/[^0-9]/g, "").length;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The element's answer, as a person reads it: the server's `label` when there
|
|
154
|
+
* is one, the value with its digits grouped when it is a number, the value
|
|
155
|
+
* verbatim otherwise.
|
|
156
|
+
*/
|
|
157
|
+
export function badgeValueText(row: CardBadgeRow, locale?: string): string {
|
|
158
|
+
const label = text(row.label);
|
|
159
|
+
if (label.length > 0) return label;
|
|
160
|
+
const raw = row.value;
|
|
161
|
+
if (raw === null || raw === undefined || raw === "") return "";
|
|
162
|
+
if (typeof raw === "boolean") return "";
|
|
163
|
+
const parsed = typeof raw === "number" ? raw : Number(raw);
|
|
164
|
+
if (typeof raw !== "object" && Number.isFinite(parsed)) {
|
|
165
|
+
const digits = statedDecimals(raw);
|
|
166
|
+
try {
|
|
167
|
+
return new Intl.NumberFormat(locale, {
|
|
168
|
+
minimumFractionDigits: digits,
|
|
169
|
+
maximumFractionDigits: digits,
|
|
170
|
+
}).format(parsed);
|
|
171
|
+
} catch {
|
|
172
|
+
return parsed.toFixed(digits);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return typeof raw === "object" ? "" : String(raw);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Is this element's value the `true` a `name`-presented boolean needs? */
|
|
179
|
+
function isTrue(raw: unknown): boolean {
|
|
180
|
+
return raw === true || raw === 1 || raw === "true";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One badge element → the text a card prints, or `undefined` for an element
|
|
185
|
+
* that has nothing to say (a false boolean, a blank value).
|
|
186
|
+
*
|
|
187
|
+
* The one place the four presentations are read. Every card surface calls
|
|
188
|
+
* this and none of them re-derives it, for the same reason `CardTarget`
|
|
189
|
+
* exists: three cards each re-deciding what a badge says is three places for
|
|
190
|
+
* "Brick · 3 · 9" to come back.
|
|
191
|
+
*/
|
|
192
|
+
export function cardBadgeText(row: CardBadgeRow, locale?: string): string | undefined {
|
|
193
|
+
const presentation = badgePresentation(row);
|
|
194
|
+
const name = text(row.name);
|
|
195
|
+
const unit = text(row.unit);
|
|
196
|
+
|
|
197
|
+
if (presentation === "name") {
|
|
198
|
+
// The name IS the badge, and only while the answer is yes.
|
|
199
|
+
return isTrue(row.value) && name.length > 0 ? name : undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const value = badgeValueText(row, locale);
|
|
203
|
+
if (value.length === 0) return undefined;
|
|
204
|
+
|
|
205
|
+
const withUnit = unit.length > 0 ? `${value} ${unit}` : value;
|
|
206
|
+
|
|
207
|
+
switch (presentation) {
|
|
208
|
+
case "value_unit":
|
|
209
|
+
return withUnit;
|
|
210
|
+
case "name_value":
|
|
211
|
+
// A SPACE, not a colon. "Floor 3" is a caption; "Floor: 3" is a form
|
|
212
|
+
// field, and a card is neither a form nor a table — the colon is the
|
|
213
|
+
// punctuation the spec list uses because a spec list IS the table.
|
|
214
|
+
// The unit rides along: a named row that dropped it reads "Mileage
|
|
215
|
+
// 20 000", which is the defect this contract exists to close.
|
|
216
|
+
return name.length > 0 ? `${name} ${withUnit}` : withUnit;
|
|
217
|
+
default:
|
|
218
|
+
// `value`, and an element the server left plain in a list that speaks
|
|
219
|
+
// the contract. The value alone, never the unit — the server said so.
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Every element that has something to say, in the server's order. */
|
|
225
|
+
export function cardBadgeTexts(
|
|
226
|
+
rows: readonly CardBadgeRow[],
|
|
227
|
+
locale?: string
|
|
228
|
+
): readonly { readonly slug: string; readonly text: string }[] {
|
|
229
|
+
const out: { slug: string; text: string }[] = [];
|
|
230
|
+
for (const row of rows) {
|
|
231
|
+
const printed = cardBadgeText(row, locale);
|
|
232
|
+
if (printed !== undefined && typeof row.slug === "string" && row.slug.length > 0) {
|
|
233
|
+
out.push({ slug: row.slug, text: printed });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
package/src/model/draft.ts
CHANGED
|
@@ -171,7 +171,11 @@ export function draftValuesFromDetail(
|
|
|
171
171
|
): ListingDraftValues {
|
|
172
172
|
const base = emptyDraftValues(options);
|
|
173
173
|
return {
|
|
174
|
-
|
|
174
|
+
// `text()` for the same reason as the draft seed above: 0.21.4 answers
|
|
175
|
+
// `category_id: null` for a row created before its category was chosen,
|
|
176
|
+
// and a seed that took it verbatim put `null` where a string is declared
|
|
177
|
+
// and crashed the first control that measured its length.
|
|
178
|
+
categoryId: text(detail.category_id),
|
|
175
179
|
title: text(detail.title),
|
|
176
180
|
description: text(detail.description),
|
|
177
181
|
price: text(detail.price),
|
|
@@ -236,7 +240,11 @@ export function draftPatchFromValues(
|
|
|
236
240
|
features: readonly FeatureDef[]
|
|
237
241
|
): ListingDraftPatch {
|
|
238
242
|
return {
|
|
239
|
-
|
|
243
|
+
// Omitted while unchosen rather than sent as `""`: a draft is allowed to
|
|
244
|
+
// have no category (0.21.4), and `""` is not "no category" on the wire —
|
|
245
|
+
// it is an empty id the serializer refuses. The category is written by
|
|
246
|
+
// whichever save follows the pick.
|
|
247
|
+
...(values.categoryId.length > 0 ? { category_id: values.categoryId } : {}),
|
|
240
248
|
title_draft: values.title,
|
|
241
249
|
description_draft: values.description,
|
|
242
250
|
price_draft: values.price.length > 0 ? values.price : null,
|
|
@@ -261,16 +269,26 @@ export function draftPatchFromValues(
|
|
|
261
269
|
}
|
|
262
270
|
|
|
263
271
|
/**
|
|
264
|
-
* The body for CREATING a draft: `
|
|
272
|
+
* The body for CREATING a draft: the category if there is one, `{}` if there
|
|
273
|
+
* is not.
|
|
265
274
|
*
|
|
266
275
|
* `perform_create` forces `owner` and `status`, and everything else has a
|
|
267
276
|
* model default, so a create that also carried the form's current contents
|
|
268
277
|
* would be a second write of data the very next `save-draft` sends anyway —
|
|
269
278
|
* and would fail the whole submission on a field the person could still fix.
|
|
270
279
|
* Create the row, then save into it.
|
|
280
|
+
*
|
|
281
|
+
* `{}` IS a valid create body since stapel-listings 0.21.4 made `category_id`
|
|
282
|
+
* nullable on a draft: a draft may exist before its category is chosen, and
|
|
283
|
+
* `publish` is where the category becomes mandatory (`publish_validation
|
|
284
|
+
* _failed` naming `category_id`). Not being able to create the row first is
|
|
285
|
+
* what left an analysis job addressed by the draft id with no id to start
|
|
286
|
+
* from (D261).
|
|
271
287
|
*/
|
|
272
|
-
export function createDraftBody(categoryId
|
|
273
|
-
return
|
|
288
|
+
export function createDraftBody(categoryId?: string): ListingDraftPatch {
|
|
289
|
+
return categoryId !== undefined && categoryId.length > 0
|
|
290
|
+
? { category_id: categoryId }
|
|
291
|
+
: {};
|
|
274
292
|
}
|
|
275
293
|
|
|
276
294
|
/**
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stored feature value as a person READS it — with its unit, and with its
|
|
3
|
+
* digits grouped the way the reader's locale groups them.
|
|
4
|
+
*
|
|
5
|
+
* ── What was on screen ────────────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Measured on the live listing page: "Power 173", "Mileage 20000", "Engine
|
|
8
|
+
* volume 2.0". Three defects in three rows:
|
|
9
|
+
*
|
|
10
|
+
* - **no unit.** 173 what. 20000 what. A spec row whose value is a bare
|
|
11
|
+
* number is a row a buyer has to guess at, and mileage is the single
|
|
12
|
+
* number a used-car buyer decides on;
|
|
13
|
+
* - **no digit grouping.** `20000` is read digit by digit; "20 000" is read
|
|
14
|
+
* at a glance. `formatFeatureValue`'s integer branch is `String(value)`
|
|
15
|
+
* (attributes-react `src/format.ts:196`), deliberately — it mirrors the
|
|
16
|
+
* engine's own `str(value)` — and the engine renders for a machine-read
|
|
17
|
+
* API, not for a spec table;
|
|
18
|
+
* - **the wrong decimal mark.** `2.0` in a Russian storefront. The engine
|
|
19
|
+
* writes `toFixed`, which is invariant by construction.
|
|
20
|
+
*
|
|
21
|
+
* ── Where the unit comes from ─────────────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* There is NO generic `unit` key anywhere in this fleet's feature contract —
|
|
24
|
+
* not on `FeatureDef` (`stapel-attributes/base.py:154-208`), not on
|
|
25
|
+
* `IntConfig`/`FloatConfig` (`attributes-react/src/generated/featureDef.ts:163`),
|
|
26
|
+
* not on the stored DAO. The unit of an `int`/`float` IS its `postfix`, free
|
|
27
|
+
* text on the type's config, with `postfix1000` as the abbreviated unit the
|
|
28
|
+
* engine switches to at a thousand. `convertible_unit` is the one type with
|
|
29
|
+
* real unit semantics (`unitType`/`unit_m`/`unit_i`), and its own formatter
|
|
30
|
+
* already appends the resolved code — so this module leaves it alone.
|
|
31
|
+
*
|
|
32
|
+
* So "render the unit from the definition" means: read `postfix`. It reaches
|
|
33
|
+
* a display surface two ways, and `model/features.ts` now tries both — the
|
|
34
|
+
* stored row's own config first (`dto_to_dao` copies `postfix` at write
|
|
35
|
+
* time), then the CATEGORY's definition of the same slug, which is the path
|
|
36
|
+
* that repairs every listing published before a catalogue gained its units.
|
|
37
|
+
*
|
|
38
|
+
* ── Why the whole formatter is not delegated ──────────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* `formatFeatureValue` is the fleet's one formatter and stays it: every type
|
|
41
|
+
* but `int` and `float` goes straight through it, untouched. The two numeric
|
|
42
|
+
* types are intercepted because the change is not a different ANSWER, it is
|
|
43
|
+
* the same answer typeset — same value, same precision rule, same
|
|
44
|
+
* `postfix1000` switch at a thousand, same translated unit, with the digits
|
|
45
|
+
* run through `Intl.NumberFormat` instead of `String()`.
|
|
46
|
+
*
|
|
47
|
+
* ── Which numbers are grouped, and which are not (D307) ────────────────────
|
|
48
|
+
*
|
|
49
|
+
* Grouping every number is how a card came to read "2 024" for a model year.
|
|
50
|
+
* A year is not a quantity: it is an identifier spelled in digits, and so is
|
|
51
|
+
* a house number, a floor, a room count. Grouping them is not a nicety
|
|
52
|
+
* applied too widely, it is the wrong reading.
|
|
53
|
+
*
|
|
54
|
+
* The rule is the one fact the catalogue already states, plus a magnitude no
|
|
55
|
+
* identifier reaches:
|
|
56
|
+
*
|
|
57
|
+
* - a feature that carries a UNIT (`prefix`, `postfix`, `postfix1000`) is a
|
|
58
|
+
* measurement — "20 000 km", "173 hp", "42 m²" — and is grouped;
|
|
59
|
+
* - a unitless value is grouped only from 10 000 up. A year is four digits
|
|
60
|
+
* for the next eight thousand of them and a count of doors is one, so the
|
|
61
|
+
* threshold sorts years from counts WITHOUT a "does this slug look like a
|
|
62
|
+
* year" heuristic — which is guesswork the moment a deployment names the
|
|
63
|
+
* slug in its own language.
|
|
64
|
+
*
|
|
65
|
+
* Grouping is switched off through `Intl` rather than around it, so a unitless
|
|
66
|
+
* float still gets the reader's DECIMAL MARK ("2,5", not "2.5"): the two are
|
|
67
|
+
* separate defects and only one of them is being repaired here.
|
|
68
|
+
*/
|
|
69
|
+
import type { FeatureDef, FeatureValueDto, FormatOptions } from "@stapel/attributes-react";
|
|
70
|
+
import { featureConfig, featureType, formatFeatureValue } from "@stapel/attributes-react";
|
|
71
|
+
|
|
72
|
+
/** The engine's own defaults (`types/int/type.py`, `types/float/type.py`). */
|
|
73
|
+
const DEFAULT_DIGITS: Readonly<Record<"int" | "float", number>> = {
|
|
74
|
+
int: 1,
|
|
75
|
+
float: 2,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function str(value: unknown): string {
|
|
79
|
+
return typeof value === "string" ? value : "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A config string the engine declares to be a translation key, resolved.
|
|
84
|
+
*
|
|
85
|
+
* `prefix`/`postfix`/`postfix1000` are collected by upstream's
|
|
86
|
+
* `get_translation_keys` unconditionally, so they go through the host's
|
|
87
|
+
* catalogue exactly as `formatFeatureValue` sends them — a Russian storefront
|
|
88
|
+
* was reading "L" and "each" off its own spec table before that landed, and
|
|
89
|
+
* this path must not reintroduce it.
|
|
90
|
+
*/
|
|
91
|
+
function translated(options: FormatOptions | undefined, raw: unknown): string {
|
|
92
|
+
const text = str(raw);
|
|
93
|
+
if (text.length === 0) return "";
|
|
94
|
+
const t = options?.t;
|
|
95
|
+
if (t === undefined) return text;
|
|
96
|
+
const resolved = t(text);
|
|
97
|
+
return resolved.length > 0 ? resolved : text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The unit a numeric feature is measured in, as the reader sees it — or `""`.
|
|
102
|
+
*
|
|
103
|
+
* `postfix1000` when the engine's own thousand switch applies, `postfix`
|
|
104
|
+
* otherwise, so the unit and the number can never disagree about scale.
|
|
105
|
+
*/
|
|
106
|
+
export function featureUnit(
|
|
107
|
+
feature: FeatureDef,
|
|
108
|
+
dto: FeatureValueDto | undefined,
|
|
109
|
+
options?: FormatOptions
|
|
110
|
+
): string {
|
|
111
|
+
const type = featureType(feature);
|
|
112
|
+
if (type !== "int" && type !== "float") return "";
|
|
113
|
+
const config = featureConfig(feature);
|
|
114
|
+
const parsed = Number(dto?.value);
|
|
115
|
+
const big = str(config["postfix1000"]).length > 0 && Number.isFinite(parsed) && Math.abs(parsed) >= 1000;
|
|
116
|
+
return translated(options, config[big ? "postfix1000" : "postfix"]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `Intl.NumberFormat` for one value, or `undefined` when the runtime has no
|
|
121
|
+
* `Intl` for this locale to offer.
|
|
122
|
+
*
|
|
123
|
+
* A bad BCP-47 tag throws `RangeError`, and a spec table that renders nothing
|
|
124
|
+
* because a host passed `"en_US"` would be a worse outcome than an ungrouped
|
|
125
|
+
* number — so the caller falls back rather than the page failing.
|
|
126
|
+
*/
|
|
127
|
+
function localized(
|
|
128
|
+
value: number,
|
|
129
|
+
digits: number,
|
|
130
|
+
locale: string | undefined,
|
|
131
|
+
useGrouping: boolean
|
|
132
|
+
): string | undefined {
|
|
133
|
+
try {
|
|
134
|
+
return new Intl.NumberFormat(locale, {
|
|
135
|
+
minimumFractionDigits: digits,
|
|
136
|
+
maximumFractionDigits: digits,
|
|
137
|
+
useGrouping,
|
|
138
|
+
}).format(value);
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Digits below which a unitless number is an identifier rather than a
|
|
145
|
+
* quantity — see the module header. */
|
|
146
|
+
const GROUPING_FLOOR = 10_000;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Does this feature measure something?
|
|
150
|
+
*
|
|
151
|
+
* The RAW config, not the translated unit: whether a value is a measurement
|
|
152
|
+
* is a fact about the catalogue, and it must not change because a host's
|
|
153
|
+
* message catalogue happens to resolve a unit key to an empty string.
|
|
154
|
+
*/
|
|
155
|
+
function hasUnit(config: Readonly<Record<string, unknown>>): boolean {
|
|
156
|
+
return (
|
|
157
|
+
str(config["prefix"]).length > 0 ||
|
|
158
|
+
str(config["postfix"]).length > 0 ||
|
|
159
|
+
str(config["postfix1000"]).length > 0
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The one numeric row, typeset — see the module header for what each piece
|
|
165
|
+
* is repairing.
|
|
166
|
+
*
|
|
167
|
+
* Returns `undefined` for a value that is not a number, so the caller falls
|
|
168
|
+
* back to the shared formatter and an unreadable row keeps saying so.
|
|
169
|
+
*/
|
|
170
|
+
function formatNumeric(
|
|
171
|
+
feature: FeatureDef,
|
|
172
|
+
dto: FeatureValueDto,
|
|
173
|
+
kind: "int" | "float",
|
|
174
|
+
options: FormatOptions | undefined
|
|
175
|
+
): string | undefined {
|
|
176
|
+
// `Number(null)` is 0 and `Number("")` is 0, so a blank row would render as
|
|
177
|
+
// a confident zero. The shared formatter's own `isBlank` gate is upstream of
|
|
178
|
+
// this branch and must be restated here rather than assumed.
|
|
179
|
+
const raw = dto.value;
|
|
180
|
+
if (raw === null || raw === undefined || raw === "") return undefined;
|
|
181
|
+
const parsed = typeof raw === "number" ? raw : Number(raw);
|
|
182
|
+
if (!Number.isFinite(parsed)) return undefined;
|
|
183
|
+
const config = featureConfig(feature);
|
|
184
|
+
const precision = config["precision"];
|
|
185
|
+
const digits =
|
|
186
|
+
typeof precision === "number" && precision >= 0 ? precision : DEFAULT_DIGITS[kind];
|
|
187
|
+
|
|
188
|
+
const prefix = translated(options, config["prefix"]);
|
|
189
|
+
const postfix1000 = str(config["postfix1000"]);
|
|
190
|
+
|
|
191
|
+
// The engine switches unit AND scale at exactly a thousand
|
|
192
|
+
// (`format_value`: `value / 1000`, trailing zeros stripped, `postfix1000`
|
|
193
|
+
// as the unit). Grouping a scaled number is harmless and keeps one code
|
|
194
|
+
// path; the trailing-zero strip is the engine's, done on the plain digits
|
|
195
|
+
// before the locale ever sees them, because "1.50" and "1,5" are the same
|
|
196
|
+
// decision made in two places.
|
|
197
|
+
if (postfix1000.length > 0 && Math.abs(parsed) >= 1000) {
|
|
198
|
+
const scaled = Number((parsed / 1000).toFixed(digits));
|
|
199
|
+
const body =
|
|
200
|
+
localized(scaled, decimalsOf(scaled, digits), options?.locale, true) ??
|
|
201
|
+
String(scaled);
|
|
202
|
+
return join(prefix, body, translated(options, postfix1000));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// `precision` drives ONLY the scaled branch of an int — the engine's plain
|
|
206
|
+
// branch is `str(value)`, and an integer never grows a decimal tail
|
|
207
|
+
// whatever the config says (D26: a live category shipped `precision: 1` on
|
|
208
|
+
// a year field and the card read "2024.0").
|
|
209
|
+
const value = kind === "int" ? Math.trunc(parsed) : parsed;
|
|
210
|
+
const fraction = kind === "int" ? 0 : digits;
|
|
211
|
+
// A measurement is grouped; a unitless number is an identifier until it is
|
|
212
|
+
// too big to be one (D307 — the card read "2 024" for a model year).
|
|
213
|
+
const group = hasUnit(config) || Math.abs(value) >= GROUPING_FLOOR;
|
|
214
|
+
const body =
|
|
215
|
+
localized(value, fraction, options?.locale, group) ?? value.toFixed(fraction);
|
|
216
|
+
return join(prefix, body, translated(options, config["postfix"]));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** How many decimals a scaled value actually has, capped at the configured
|
|
220
|
+
* precision — the engine's `.rstrip('0').rstrip('.')`, expressed as a count
|
|
221
|
+
* so `Intl` can do the rendering. */
|
|
222
|
+
function decimalsOf(value: number, cap: number): number {
|
|
223
|
+
const tail = value.toFixed(cap).split(".")[1] ?? "";
|
|
224
|
+
return tail.replace(/0+$/, "").length;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `prefix` + body + unit, with the single space the engine puts between a
|
|
228
|
+
* number and its unit and no space at all where there is no unit. */
|
|
229
|
+
function join(prefix: string, body: string, unit: string): string {
|
|
230
|
+
return unit.length > 0 ? `${prefix}${body} ${unit}` : `${prefix}${body}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A stored value as a spec row prints it: `formatFeatureValue` for every type
|
|
235
|
+
* but the two numeric ones, which are typeset here instead.
|
|
236
|
+
*
|
|
237
|
+
* Signature-compatible with `formatFeatureValue` on purpose — a surface
|
|
238
|
+
* swaps one for the other and nothing else changes.
|
|
239
|
+
*/
|
|
240
|
+
export function formatSpecValue(
|
|
241
|
+
feature: FeatureDef,
|
|
242
|
+
dto: FeatureValueDto | undefined,
|
|
243
|
+
options?: FormatOptions
|
|
244
|
+
): string | undefined {
|
|
245
|
+
const type = featureType(feature);
|
|
246
|
+
if (dto !== undefined && (type === "int" || type === "float")) {
|
|
247
|
+
const typeset = formatNumeric(feature, dto, type, options);
|
|
248
|
+
if (typeset !== undefined) return typeset;
|
|
249
|
+
}
|
|
250
|
+
return formatFeatureValue(feature, dto, options);
|
|
251
|
+
}
|
package/src/model/features.ts
CHANGED
|
@@ -317,6 +317,64 @@ function selectOptions(
|
|
|
317
317
|
return [...table].map(([value, label]) => ({ value, label }));
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The config keys that carry a value's UNIT, in the order they belong to.
|
|
322
|
+
*
|
|
323
|
+
* There is no generic `unit` key anywhere in this contract — the unit of an
|
|
324
|
+
* `int`/`float` is its `postfix` (free text on the type's config, with
|
|
325
|
+
* `postfix1000` as the abbreviated unit the engine switches to at a
|
|
326
|
+
* thousand), and `convertible_unit` states its own with `unitType`/`unit_m`/
|
|
327
|
+
* `unit_i`. `prefix` rides along because it is the same fact on the other
|
|
328
|
+
* side of the number (a currency mark, a «~»).
|
|
329
|
+
*
|
|
330
|
+
* `precision` is deliberately NOT here. `dto_to_dao` writes it on every
|
|
331
|
+
* numeric row (defaulted, never absent), so it could only ever be adopted
|
|
332
|
+
* onto a row from some older server — where the stored value has already
|
|
333
|
+
* been ROUNDED to the precision it was written with, and re-rendering it at
|
|
334
|
+
* today's would print decimals the record does not contain.
|
|
335
|
+
*/
|
|
336
|
+
const UNIT_KEYS: readonly string[] = [
|
|
337
|
+
"prefix",
|
|
338
|
+
"postfix",
|
|
339
|
+
"postfix1000",
|
|
340
|
+
"unitType",
|
|
341
|
+
"unit_m",
|
|
342
|
+
"unit_i",
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The unit the CATEGORY declares, for a stored row that carries none.
|
|
347
|
+
*
|
|
348
|
+
* Measured on the live listing page: "Power 173", "Mileage 20000" — bare
|
|
349
|
+
* numbers, in a deployment whose detail page holds the category's own feature
|
|
350
|
+
* defs and passes them in for exactly this kind of repair. `dto_to_dao`
|
|
351
|
+
* copies `postfix` at WRITE time, so a listing published before its category
|
|
352
|
+
* gained a unit keeps printing without one for the rest of its life, and so
|
|
353
|
+
* does every row written by a server that predates the key.
|
|
354
|
+
*
|
|
355
|
+
* Same rule as the option table two functions up, and for the same reason:
|
|
356
|
+
* the stored row wins wherever it said anything, because it is what the
|
|
357
|
+
* listing was published with, and the category fills only the silence. An
|
|
358
|
+
* empty string counts as silence — `dto_to_dao` writes `postfix=None` as
|
|
359
|
+
* absent, but a catalogue that once held `""` should not out-rank a
|
|
360
|
+
* catalogue that now holds "km".
|
|
361
|
+
*/
|
|
362
|
+
function adoptedUnits(
|
|
363
|
+
config: Readonly<Record<string, unknown>>,
|
|
364
|
+
categoryDef: FeatureDef | undefined
|
|
365
|
+
): Readonly<Record<string, string>> | undefined {
|
|
366
|
+
const declared = categoryDef?.config;
|
|
367
|
+
if (declared === undefined) return undefined;
|
|
368
|
+
const out: Record<string, string> = {};
|
|
369
|
+
for (const key of UNIT_KEYS) {
|
|
370
|
+
const stored = config[key];
|
|
371
|
+
if (typeof stored === "string" && stored.length > 0) continue;
|
|
372
|
+
const value = (declared as Readonly<Record<string, unknown>>)[key];
|
|
373
|
+
if (typeof value === "string" && value.length > 0) out[key] = value;
|
|
374
|
+
}
|
|
375
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
376
|
+
}
|
|
377
|
+
|
|
320
378
|
/**
|
|
321
379
|
* The category's option TREE, adopted whole for a `hierarchical_select` that
|
|
322
380
|
* stored none — see {@link TREE_VALUED}.
|
|
@@ -365,6 +423,8 @@ function featureView(
|
|
|
365
423
|
const options =
|
|
366
424
|
selectOptions(dao, config, categoryDef) ?? adoptedTree(dao, config, categoryDef);
|
|
367
425
|
if (options !== undefined) config["options"] = options;
|
|
426
|
+
const units = adoptedUnits(config, categoryDef);
|
|
427
|
+
if (units !== undefined) Object.assign(config, units);
|
|
368
428
|
|
|
369
429
|
const visibility = storedVisibility(dao.visibility);
|
|
370
430
|
const feature: FeatureDef = {
|