@stapel/listings-react 0.24.0 → 0.25.1

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 (43) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/MODULE.md +4 -0
  3. package/README.md +19 -0
  4. package/dist/api/generated/schema.d.ts +7 -5
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/listingsApi.d.ts +5 -3
  7. package/dist/api/listingsApi.d.ts.map +1 -1
  8. package/dist/api/listingsApi.js.map +1 -1
  9. package/dist/api/types.d.ts +5 -1
  10. package/dist/api/types.d.ts.map +1 -1
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/headless/ListingComposer.d.ts +20 -2
  13. package/dist/headless/ListingComposer.d.ts.map +1 -1
  14. package/dist/headless/ListingComposer.js +19 -4
  15. package/dist/headless/ListingComposer.js.map +1 -1
  16. package/dist/i18n/es.js +1 -1
  17. package/dist/i18n/es.js.map +1 -1
  18. package/dist/i18n/keys.js +1 -1
  19. package/dist/i18n/keys.js.map +1 -1
  20. package/dist/i18n/ru.js +1 -1
  21. package/dist/i18n/ru.js.map +1 -1
  22. package/dist/model/draft.d.ts +10 -2
  23. package/dist/model/draft.d.ts.map +1 -1
  24. package/dist/model/draft.js +22 -4
  25. package/dist/model/draft.js.map +1 -1
  26. package/dist/model/featureText.d.ts +22 -0
  27. package/dist/model/featureText.d.ts.map +1 -1
  28. package/dist/model/featureText.js +23 -3
  29. package/dist/model/featureText.js.map +1 -1
  30. package/llms.txt +1 -1
  31. package/manifest.json +1 -1
  32. package/nav-manifest.json +1 -1
  33. package/package.json +6 -6
  34. package/src/analytics/generated/events.json +1 -1
  35. package/src/api/generated/schema.ts +7 -5
  36. package/src/api/listingsApi.ts +5 -3
  37. package/src/api/types.ts +5 -1
  38. package/src/headless/ListingComposer.tsx +39 -5
  39. package/src/i18n/es.ts +1 -1
  40. package/src/i18n/keys.ts +1 -1
  41. package/src/i18n/ru.ts +1 -1
  42. package/src/model/draft.ts +23 -5
  43. package/src/model/featureText.ts +55 -3
@@ -171,7 +171,11 @@ export function draftValuesFromDetail(
171
171
  ): ListingDraftValues {
172
172
  const base = emptyDraftValues(options);
173
173
  return {
174
- categoryId: detail.category_id,
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
- category_id: values.categoryId,
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: `category_id` and nothing else.
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: string): ListingDraftPatch {
273
- return { category_id: categoryId };
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
  /**
@@ -43,6 +43,28 @@
43
43
  * the same answer typeset — same value, same precision rule, same
44
44
  * `postfix1000` switch at a thousand, same translated unit, with the digits
45
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.
46
68
  */
47
69
  import type { FeatureDef, FeatureValueDto, FormatOptions } from "@stapel/attributes-react";
48
70
  import { featureConfig, featureType, formatFeatureValue } from "@stapel/attributes-react";
@@ -102,17 +124,42 @@ export function featureUnit(
102
124
  * because a host passed `"en_US"` would be a worse outcome than an ungrouped
103
125
  * number — so the caller falls back rather than the page failing.
104
126
  */
105
- function grouped(value: number, digits: number, locale: string | undefined): string | undefined {
127
+ function localized(
128
+ value: number,
129
+ digits: number,
130
+ locale: string | undefined,
131
+ useGrouping: boolean
132
+ ): string | undefined {
106
133
  try {
107
134
  return new Intl.NumberFormat(locale, {
108
135
  minimumFractionDigits: digits,
109
136
  maximumFractionDigits: digits,
137
+ useGrouping,
110
138
  }).format(value);
111
139
  } catch {
112
140
  return undefined;
113
141
  }
114
142
  }
115
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
+
116
163
  /**
117
164
  * The one numeric row, typeset — see the module header for what each piece
118
165
  * is repairing.
@@ -150,7 +197,8 @@ function formatNumeric(
150
197
  if (postfix1000.length > 0 && Math.abs(parsed) >= 1000) {
151
198
  const scaled = Number((parsed / 1000).toFixed(digits));
152
199
  const body =
153
- grouped(scaled, decimalsOf(scaled, digits), options?.locale) ?? String(scaled);
200
+ localized(scaled, decimalsOf(scaled, digits), options?.locale, true) ??
201
+ String(scaled);
154
202
  return join(prefix, body, translated(options, postfix1000));
155
203
  }
156
204
 
@@ -160,7 +208,11 @@ function formatNumeric(
160
208
  // a year field and the card read "2024.0").
161
209
  const value = kind === "int" ? Math.trunc(parsed) : parsed;
162
210
  const fraction = kind === "int" ? 0 : digits;
163
- const body = grouped(value, fraction, options?.locale) ?? value.toFixed(fraction);
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);
164
216
  return join(prefix, body, translated(options, config["postfix"]));
165
217
  }
166
218