@stapel/listings-react 0.14.0 → 0.15.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.
@@ -408,6 +408,10 @@ export interface components {
408
408
  title?: boolean | null;
409
409
  badge?: boolean | null;
410
410
  translate?: string | null;
411
+ visibility?: string | null;
412
+ verification?: {
413
+ [key: string]: unknown;
414
+ } | null;
411
415
  /**
412
416
  * @description discriminator enum property added by openapi-typescript
413
417
  * @enum {string}
@@ -433,6 +437,10 @@ export interface components {
433
437
  title?: boolean | null;
434
438
  badge?: boolean | null;
435
439
  translate?: string | null;
440
+ visibility?: string | null;
441
+ verification?: {
442
+ [key: string]: unknown;
443
+ } | null;
436
444
  /**
437
445
  * @description discriminator enum property added by openapi-typescript
438
446
  * @enum {string}
@@ -464,6 +472,10 @@ export interface components {
464
472
  title?: boolean | null;
465
473
  badge?: boolean | null;
466
474
  translate?: string | null;
475
+ visibility?: string | null;
476
+ verification?: {
477
+ [key: string]: unknown;
478
+ } | null;
467
479
  /**
468
480
  * @description discriminator enum property added by openapi-typescript
469
481
  * @enum {string}
@@ -539,6 +551,10 @@ export interface components {
539
551
  title?: boolean | null;
540
552
  badge?: boolean | null;
541
553
  translate?: string | null;
554
+ visibility?: string | null;
555
+ verification?: {
556
+ [key: string]: unknown;
557
+ } | null;
542
558
  /**
543
559
  * @description discriminator enum property added by openapi-typescript
544
560
  * @enum {string}
@@ -568,6 +584,10 @@ export interface components {
568
584
  title?: boolean | null;
569
585
  badge?: boolean | null;
570
586
  translate?: string | null;
587
+ visibility?: string | null;
588
+ verification?: {
589
+ [key: string]: unknown;
590
+ } | null;
571
591
  /**
572
592
  * @description discriminator enum property added by openapi-typescript
573
593
  * @enum {string}
@@ -595,6 +615,10 @@ export interface components {
595
615
  title?: boolean | null;
596
616
  badge?: boolean | null;
597
617
  translate?: string | null;
618
+ visibility?: string | null;
619
+ verification?: {
620
+ [key: string]: unknown;
621
+ } | null;
598
622
  /**
599
623
  * @description discriminator enum property added by openapi-typescript
600
624
  * @enum {string}
@@ -618,6 +642,10 @@ export interface components {
618
642
  title?: boolean | null;
619
643
  badge?: boolean | null;
620
644
  translate?: string | null;
645
+ visibility?: string | null;
646
+ verification?: {
647
+ [key: string]: unknown;
648
+ } | null;
621
649
  /**
622
650
  * @description discriminator enum property added by openapi-typescript
623
651
  * @enum {string}
@@ -649,6 +677,10 @@ export interface components {
649
677
  title?: boolean | null;
650
678
  badge?: boolean | null;
651
679
  translate?: string | null;
680
+ visibility?: string | null;
681
+ verification?: {
682
+ [key: string]: unknown;
683
+ } | null;
652
684
  /**
653
685
  * @description discriminator enum property added by openapi-typescript
654
686
  * @enum {string}
@@ -672,6 +704,10 @@ export interface components {
672
704
  title?: boolean | null;
673
705
  badge?: boolean | null;
674
706
  translate?: string | null;
707
+ visibility?: string | null;
708
+ verification?: {
709
+ [key: string]: unknown;
710
+ } | null;
675
711
  /**
676
712
  * @description discriminator enum property added by openapi-typescript
677
713
  * @enum {string}
@@ -707,8 +743,36 @@ export interface components {
707
743
  price_base?: string | null;
708
744
  currency?: string;
709
745
  readonly images: string[] | null;
710
- readonly features_title: components["schemas"]["FeatureDao"][];
711
- readonly features_badges: components["schemas"]["FeatureDao"][];
746
+ readonly features_title: (components["schemas"]["FeatureDao"] | {
747
+ slug?: string;
748
+ type?: string;
749
+ name?: string | null;
750
+ order?: number | null;
751
+ translate?: string | null;
752
+ /** @enum {unknown} */
753
+ visibility?: "owner" | "staff";
754
+ verification?: {
755
+ [key: string]: unknown;
756
+ };
757
+ /** @constant */
758
+ redacted: true;
759
+ present: boolean;
760
+ })[];
761
+ readonly features_badges: (components["schemas"]["FeatureDao"] | {
762
+ slug?: string;
763
+ type?: string;
764
+ name?: string | null;
765
+ order?: number | null;
766
+ translate?: string | null;
767
+ /** @enum {unknown} */
768
+ visibility?: "owner" | "staff";
769
+ verification?: {
770
+ [key: string]: unknown;
771
+ };
772
+ /** @constant */
773
+ redacted: true;
774
+ present: boolean;
775
+ })[];
712
776
  location_label?: string;
713
777
  geohash?: string;
714
778
  /** Format: decimal */
@@ -743,9 +807,51 @@ export interface components {
743
807
  lat?: string | null;
744
808
  /** Format: decimal */
745
809
  lon?: string | null;
746
- readonly features: components["schemas"]["FeatureDao"][];
747
- readonly features_title: components["schemas"]["FeatureDao"][];
748
- readonly features_badges: components["schemas"]["FeatureDao"][];
810
+ readonly features: (components["schemas"]["FeatureDao"] | {
811
+ slug?: string;
812
+ type?: string;
813
+ name?: string | null;
814
+ order?: number | null;
815
+ translate?: string | null;
816
+ /** @enum {unknown} */
817
+ visibility?: "owner" | "staff";
818
+ verification?: {
819
+ [key: string]: unknown;
820
+ };
821
+ /** @constant */
822
+ redacted: true;
823
+ present: boolean;
824
+ })[];
825
+ readonly features_title: (components["schemas"]["FeatureDao"] | {
826
+ slug?: string;
827
+ type?: string;
828
+ name?: string | null;
829
+ order?: number | null;
830
+ translate?: string | null;
831
+ /** @enum {unknown} */
832
+ visibility?: "owner" | "staff";
833
+ verification?: {
834
+ [key: string]: unknown;
835
+ };
836
+ /** @constant */
837
+ redacted: true;
838
+ present: boolean;
839
+ })[];
840
+ readonly features_badges: (components["schemas"]["FeatureDao"] | {
841
+ slug?: string;
842
+ type?: string;
843
+ name?: string | null;
844
+ order?: number | null;
845
+ translate?: string | null;
846
+ /** @enum {unknown} */
847
+ visibility?: "owner" | "staff";
848
+ verification?: {
849
+ [key: string]: unknown;
850
+ };
851
+ /** @constant */
852
+ redacted: true;
853
+ present: boolean;
854
+ })[];
749
855
  readonly features_search: unknown;
750
856
  status?: components["schemas"]["StatusD41Enum"];
751
857
  moderation_status?: components["schemas"]["ModerationStatusEnum"];
@@ -853,8 +959,36 @@ export interface components {
853
959
  price_base?: string | null;
854
960
  currency?: string;
855
961
  readonly images: string[] | null;
856
- readonly features_title: components["schemas"]["FeatureDao"][];
857
- readonly features_badges: components["schemas"]["FeatureDao"][];
962
+ readonly features_title: (components["schemas"]["FeatureDao"] | {
963
+ slug?: string;
964
+ type?: string;
965
+ name?: string | null;
966
+ order?: number | null;
967
+ translate?: string | null;
968
+ /** @enum {unknown} */
969
+ visibility?: "owner" | "staff";
970
+ verification?: {
971
+ [key: string]: unknown;
972
+ };
973
+ /** @constant */
974
+ redacted: true;
975
+ present: boolean;
976
+ })[];
977
+ readonly features_badges: (components["schemas"]["FeatureDao"] | {
978
+ slug?: string;
979
+ type?: string;
980
+ name?: string | null;
981
+ order?: number | null;
982
+ translate?: string | null;
983
+ /** @enum {unknown} */
984
+ visibility?: "owner" | "staff";
985
+ verification?: {
986
+ [key: string]: unknown;
987
+ };
988
+ /** @constant */
989
+ redacted: true;
990
+ present: boolean;
991
+ })[];
858
992
  location_label?: string;
859
993
  geohash?: string;
860
994
  /** Format: decimal */
@@ -955,6 +1089,10 @@ export interface components {
955
1089
  title?: boolean | null;
956
1090
  badge?: boolean | null;
957
1091
  translate?: string | null;
1092
+ visibility?: string | null;
1093
+ verification?: {
1094
+ [key: string]: unknown;
1095
+ } | null;
958
1096
  /**
959
1097
  * @description discriminator enum property added by openapi-typescript
960
1098
  * @enum {string}
@@ -981,6 +1119,10 @@ export interface components {
981
1119
  title?: boolean | null;
982
1120
  badge?: boolean | null;
983
1121
  translate?: string | null;
1122
+ visibility?: string | null;
1123
+ verification?: {
1124
+ [key: string]: unknown;
1125
+ } | null;
984
1126
  /**
985
1127
  * @description discriminator enum property added by openapi-typescript
986
1128
  * @enum {string}
@@ -1007,12 +1149,17 @@ export interface components {
1007
1149
  title?: boolean | null;
1008
1150
  badge?: boolean | null;
1009
1151
  translate?: string | null;
1152
+ visibility?: string | null;
1153
+ verification?: {
1154
+ [key: string]: unknown;
1155
+ } | null;
1010
1156
  /**
1011
1157
  * @description discriminator enum property added by openapi-typescript
1012
1158
  * @enum {string}
1013
1159
  */
1014
1160
  type: "select";
1015
1161
  value?: string[];
1162
+ labels?: string[];
1016
1163
  uiStyle?: (components["schemas"]["UiStyleEnum"] | components["schemas"]["NullEnum"]) | null;
1017
1164
  maxSelected?: number | null;
1018
1165
  };
@@ -1045,6 +1192,10 @@ export interface components {
1045
1192
  title?: boolean | null;
1046
1193
  badge?: boolean | null;
1047
1194
  translate?: string | null;
1195
+ visibility?: string | null;
1196
+ verification?: {
1197
+ [key: string]: unknown;
1198
+ } | null;
1048
1199
  /**
1049
1200
  * @description discriminator enum property added by openapi-typescript
1050
1201
  * @enum {string}
package/src/api/types.ts CHANGED
@@ -194,6 +194,35 @@ export interface ListingFeatureDao {
194
194
  /** Rendered as a badge on the card. */
195
195
  readonly badge?: boolean | null;
196
196
  readonly translate?: string | null;
197
+ /**
198
+ * Which audience may READ the stored value (stapel-attributes 0.8.1).
199
+ * Stamped into the DAO at write time and absent on a public row, so an
200
+ * existing row is byte-identical. `owner`/`staff` mark an identifier of one
201
+ * physical unit — a VIN, an IMEI, a serial — which stapel-listings 0.12.0
202
+ * withholds from a reader who is not entitled to it.
203
+ */
204
+ readonly visibility?: string | null;
205
+ /**
206
+ * `true` on a **stub**: the row survived redaction as its own identity with
207
+ * no value at all (`RedactedFeatureDao`). It is kept in place and in order
208
+ * so the public spec table has the same rows as the seller's own — a buyer
209
+ * can see the field exists rather than not knowing it was ever asked.
210
+ */
211
+ readonly redacted?: boolean;
212
+ /**
213
+ * On a stub: whether the seller actually filled the withheld value in.
214
+ * A FACT this system observed, and the only one it has — see
215
+ * {@link ListingFeatureDao.verification}.
216
+ */
217
+ readonly present?: boolean;
218
+ /**
219
+ * On a stub: the result of an outside check of the value, passed through
220
+ * redaction verbatim. **Absent for everything in the fleet today** — no
221
+ * product runs a VIN or an IMEI check — which is exactly why `present`
222
+ * alone may never be rendered as "verified". stapel-attributes deliberately
223
+ * leaves the `status` vocabulary to whichever product runs the check.
224
+ */
225
+ readonly verification?: Readonly<Record<string, unknown>>;
197
226
  /** The type's own config keys (`prefix`, `precision`, `unitType`, …). */
198
227
  readonly [key: string]: unknown;
199
228
  }
@@ -50,10 +50,10 @@ import {
50
50
  } from "@stapel/core";
51
51
  import { spacing } from "@stapel/tokens";
52
52
  import { FeatureValueList } from "@stapel/attributes-react/default";
53
- import { formatFeatureValue } from "@stapel/attributes-react";
53
+ import { formatFeatureValue, isRedactedValue } from "@stapel/attributes-react";
54
54
  import { useListingDetail } from "../headless/ListingDetail.js";
55
55
  import { useListingActions } from "../headless/ListingActions.js";
56
- import { asFeatureDaoList, featuresDtoFromDaoList } from "../model/features.js";
56
+ import { asFeatureDaoList, featureValuesForDisplay } from "../model/features.js";
57
57
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
58
58
  import { HeartIcon } from "./icons.js";
59
59
  import { ListingPhoto } from "./ListingPhoto.js";
@@ -218,6 +218,14 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
218
218
  {bag.titleFeatures.length > 0 ? (
219
219
  <Typography.Text type="secondary" data-testid="listings-detail-title-features">
220
220
  {bag.titleFeatures
221
+ // A hidden value is never part of a title: the server
222
+ // keeps one out of `features_title` entirely, and
223
+ // `formatFeatureValue` refuses a stub besides (it carries
224
+ // no value, so there is nothing to format). The filter is
225
+ // the third belt, and it is here rather than at the
226
+ // formatter's edge because THIS is the line where a
227
+ // leaked identifier would be read out loud.
228
+ .filter((view) => !isRedactedValue(view.value))
221
229
  .map((view) =>
222
230
  formatFeatureValue(view.feature, view.value, { t, locale })
223
231
  )
@@ -332,8 +340,15 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
332
340
  ) : (
333
341
  <FeatureValueList
334
342
  features={bag.features.map((view) => view.feature)}
335
- values={featuresDtoFromDaoList(
336
- asFeatureDaoList(listing.features)
343
+ // The DISPLAY envelope, not the edit one: a redacted row
344
+ // keeps its place in the table and says the seller supplied
345
+ // the value. `featuresDtoFromDaoList` deliberately drops a
346
+ // stub, because it is what seeds a composer.
347
+ values={featureValuesForDisplay(
348
+ asFeatureDaoList(listing.features),
349
+ props.categoryFeatures !== undefined
350
+ ? { categoryFeatures: props.categoryFeatures }
351
+ : {}
337
352
  )}
338
353
  />
339
354
  )}
package/src/index.ts CHANGED
@@ -148,6 +148,7 @@ export type {
148
148
  export {
149
149
  asFeatureDaoList,
150
150
  featureFromDao,
151
+ featureValuesForDisplay,
151
152
  featuresDtoFromDaoList,
152
153
  featuresFromDaoList,
153
154
  unreadableFeatureCount,
@@ -97,7 +97,7 @@
97
97
  * and an unknown key resolves to itself either way.
98
98
  */
99
99
  import type { FeatureDef, FeatureValueDto } from "@stapel/attributes-react";
100
- import { featureType } from "@stapel/attributes-react";
100
+ import { featureType, isRedactedValue } from "@stapel/attributes-react";
101
101
  import type { ListingFeatureDao, ListingFeatureView } from "../api/types.js";
102
102
 
103
103
  /**
@@ -111,7 +111,30 @@ import type { ListingFeatureDao, ListingFeatureView } from "../api/types.js";
111
111
  * two types print. For `select` the key is simply inert — its formatter reads
112
112
  * `options` and nothing else — so carrying it costs a reference.
113
113
  */
114
- const ENVELOPE = new Set(["slug", "value", "name", "order", "title", "badge"]);
114
+ const ENVELOPE = new Set([
115
+ "slug",
116
+ "value",
117
+ "name",
118
+ "order",
119
+ "title",
120
+ "badge",
121
+ // ── The visibility axis (stapel-attributes 0.8.1 / stapel-listings 0.12.0) ──
122
+ //
123
+ // These four are envelope for exactly the reason the six above are: they
124
+ // describe the ROW, not the type, and no formatter has ever read one. But
125
+ // they matter more than the others, because the alternative is not noise —
126
+ // it is a marker reaching the display BY ACCIDENT, through the index
127
+ // signature, with nothing stating that it did.
128
+ //
129
+ // `visibility` is a genuine `FeatureDef` field and is lifted onto the
130
+ // definition below; `redacted`/`present`/`verification` describe THIS
131
+ // reader's access to THIS stored value, so they ride the value envelope,
132
+ // which is where `@stapel/attributes-react`'s predicates read them.
133
+ "visibility",
134
+ "redacted",
135
+ "present",
136
+ "verification",
137
+ ]);
115
138
 
116
139
  /** The type whose stored `value` is a flat list of option keys rather than the
117
140
  * things themselves, and whose copy therefore has to be repaired pairwise.
@@ -139,6 +162,32 @@ function isTranslateMode(value: unknown): value is "all" | "title" | "none" {
139
162
  return value === "all" || value === "title" || value === "none";
140
163
  }
141
164
 
165
+ /**
166
+ * The stamped `FeatureDef.visibility`, or `undefined` when the row is public.
167
+ *
168
+ * `public` is stamped as nothing at all upstream (`dataclass_to_dict_no_none`
169
+ * drops it), which is why an existing public row is byte-identical after the
170
+ * axis landed — so an absent key means public and is dropped here too.
171
+ *
172
+ * A string that is NOT one of the three becomes `"staff"`, the most
173
+ * restrictive one. Python raises `UnknownVisibility` on the same input; this
174
+ * side has nobody to raise at, so it takes the only direction that cannot
175
+ * leak — a typo must not publish a VIN. The engine normalizes at write time,
176
+ * so this is the belt on a stored row nobody expects to see.
177
+ */
178
+ function storedVisibility(value: unknown): "public" | "owner" | "staff" | undefined {
179
+ if (typeof value !== "string" || value.length === 0 || value === "public") return undefined;
180
+ return value === "owner" || value === "staff" ? value : "staff";
181
+ }
182
+
183
+ /** A `verification` result, when the row carries one. An object and nothing
184
+ * else: the shape belongs to whichever product ran the check, and this
185
+ * projection passes it through rather than describing it. */
186
+ function storedVerification(value: unknown): Readonly<Record<string, unknown>> | undefined {
187
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
188
+ return value as Readonly<Record<string, unknown>>;
189
+ }
190
+
142
191
  /**
143
192
  * The category's feature defs, for a display surface that has them.
144
193
  *
@@ -317,6 +366,7 @@ function featureView(
317
366
  selectOptions(dao, config, categoryDef) ?? adoptedTree(dao, config, categoryDef);
318
367
  if (options !== undefined) config["options"] = options;
319
368
 
369
+ const visibility = storedVisibility(dao.visibility);
320
370
  const feature: FeatureDef = {
321
371
  slug: dao.slug,
322
372
  config,
@@ -326,8 +376,38 @@ function featureView(
326
376
  // the three is dropped rather than smuggled through: `FeatureDef`'s own
327
377
  // default is `all`, which is what the engine falls back to anyway.
328
378
  ...(isTranslateMode(dao.translate) ? { translate: dao.translate } : {}),
379
+ // A genuine `FeatureDef` field, so it goes on the DEFINITION rather than
380
+ // into `config`: it is what `isPublicFeature` reads to keep a hidden
381
+ // value off a badge strip.
382
+ ...(visibility !== undefined ? { visibility } : {}),
329
383
  };
330
384
 
385
+ // A REDACTED STUB — a row this reader may not see — carries no `value` key
386
+ // at all, and this envelope must not invent one. What it carries instead is
387
+ // what the system honestly observed: `present` (did the seller answer) and,
388
+ // reserved for the day some product actually runs a VIN or an IMEI check,
389
+ // `verification`. `@stapel/attributes-react`'s `<FeatureValueList/>` reads
390
+ // exactly these and prints "provided by the seller" — never "verified".
391
+ //
392
+ // The row is built even when `type` is missing, which the normal path below
393
+ // refuses: an unkeyable value is a row a buyer cannot be told about, but a
394
+ // stub's whole content is "this field exists and was answered", and that is
395
+ // still true without a type slug.
396
+ if (dao.redacted === true) {
397
+ const verification = storedVerification(dao.verification);
398
+ const stub: FeatureValueDto = {
399
+ type: typeof dao.type === "string" ? dao.type : "",
400
+ // No stored answer exists on this side of the wire. Spelled rather than
401
+ // omitted only because `FeatureValueDto.value` is a required key;
402
+ // `isBlank(undefined)` is true, so no formatter prints anything for it.
403
+ value: undefined,
404
+ redacted: true,
405
+ present: dao.present === true,
406
+ ...(verification !== undefined ? { verification } : {}),
407
+ };
408
+ return { feature, value: stub };
409
+ }
410
+
331
411
  const value: FeatureValueDto | undefined =
332
412
  typeof dao.type === "string" && dao.type.length > 0
333
413
  ? { type: dao.type, value: dao.value }
@@ -365,12 +445,55 @@ export function unreadableFeatureCount(
365
445
  * a composer reopening a PUBLISHED listing needs: `draftValuesFromDetail`
366
446
  * seeds its editors from the published values, and the published values live
367
447
  * only in the DAO list.
448
+ *
449
+ * ── Why a redacted stub is DROPPED here, and not passed through ────────────
450
+ *
451
+ * This is the EDIT envelope, and the only one that can destroy data. A stub
452
+ * has no value; seeding an editor from it would put `undefined` in the
453
+ * composer's draft under the seller's own slug, and the next save would write
454
+ * that back — blanking a stored VIN the seller never touched and cannot see
455
+ * was blanked. Dropping the row instead leaves the field empty in the form
456
+ * and untouched in the record, which is the recoverable failure.
457
+ *
458
+ * A composer reopening a listing belongs to its OWNER, whose read is
459
+ * unredacted, so a stub reaching this function means something upstream
460
+ * already went wrong — a cached anonymous payload, a viewer id that did not
461
+ * arrive, a host wiring the public detail into an edit form. It fails safe
462
+ * rather than fails loudly on purpose: there is no honest recovery from "the
463
+ * value was withheld from the person editing it", and refusing the whole seed
464
+ * would take the other twenty answers down with it.
465
+ *
466
+ * {@link featureValuesForDisplay} is the other half of this split: a spec
467
+ * table WANTS the stub, because "the seller answered this" is exactly what it
468
+ * has to say.
368
469
  */
369
470
  export function featuresDtoFromDaoList(
370
471
  daos: readonly ListingFeatureDao[] | null | undefined
371
472
  ): Readonly<Record<string, FeatureValueDto>> {
372
473
  const out: Record<string, FeatureValueDto> = {};
373
474
  for (const view of featuresFromDaoList(daos)) {
475
+ if (view.value === undefined || isRedactedValue(view.value)) continue;
476
+ out[view.feature.slug] = view.value;
477
+ }
478
+ return out;
479
+ }
480
+
481
+ /**
482
+ * A stored projection → the envelope a DISPLAY component reads, redacted
483
+ * stubs and all.
484
+ *
485
+ * Same shape as {@link featuresDtoFromDaoList} and a different job, which is
486
+ * why it is a different function rather than a flag: a stub belongs in a spec
487
+ * table (in place, in order, saying the seller answered) and must never reach
488
+ * an editor. A boolean argument would put the destructive default one
489
+ * forgotten parameter away.
490
+ */
491
+ export function featureValuesForDisplay(
492
+ daos: readonly ListingFeatureDao[] | null | undefined,
493
+ source: FeatureCopySource = {}
494
+ ): Readonly<Record<string, FeatureValueDto>> {
495
+ const out: Record<string, FeatureValueDto> = {};
496
+ for (const view of featuresFromDaoList(daos, source)) {
374
497
  if (view.value !== undefined) out[view.feature.slug] = view.value;
375
498
  }
376
499
  return out;