@stapel/listings-react 0.10.0 → 0.12.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 +197 -0
- package/dist/api/types.d.ts +10 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/ListingCard.d.ts +2 -2
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +8 -2
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +22 -62
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +228 -56
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +2 -2
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +6 -1
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts +2 -2
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +7 -2
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/types.d.ts +21 -0
- package/dist/default/types.d.ts.map +1 -1
- package/dist/headless/ListingComposer.d.ts +11 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +31 -6
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts +16 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +13 -4
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +10 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +16 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +4 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/features.d.ts +90 -20
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +148 -17
- package/dist/model/features.js.map +1 -1
- package/dist/model/validation.d.ts +32 -0
- package/dist/model/validation.d.ts.map +1 -1
- package/dist/model/validation.js +82 -1
- package/dist/model/validation.js.map +1 -1
- package/dist/nav/manifest.d.ts.map +1 -1
- package/dist/nav/manifest.js +2 -0
- package/dist/nav/manifest.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +8 -1
- package/nav-manifest.json +3 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +10 -0
- package/src/default/ListingCard.tsx +17 -4
- package/src/default/ListingComposerPage.tsx +221 -36
- package/src/default/ListingDetailPane.tsx +10 -6
- package/src/default/ListingSerpCard.tsx +13 -4
- package/src/default/index.ts +7 -2
- package/src/default/types.ts +22 -0
- package/src/headless/ListingComposer.tsx +47 -6
- package/src/headless/ListingDetail.tsx +37 -10
- package/src/i18n/es.ts +5 -0
- package/src/i18n/keys.ts +17 -0
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +3 -0
- package/src/model/features.ts +253 -35
- package/src/model/validation.ts +84 -1
- package/src/nav/manifest.ts +2 -0
package/src/model/features.ts
CHANGED
|
@@ -22,63 +22,268 @@
|
|
|
22
22
|
* `order`, `title`, `badge`) are simply ignored by every formatter. Copying a
|
|
23
23
|
* hand-picked subset instead would be a list to keep in step with ten types.
|
|
24
24
|
*
|
|
25
|
-
* ── What a DAO does NOT carry, and the
|
|
25
|
+
* ── What a DAO does NOT carry, and the tables that repair it ───────────────
|
|
26
26
|
*
|
|
27
27
|
* `select`'s `options`. `SelectType.dto_to_dao` stores the chosen VALUES and
|
|
28
28
|
* the ui config, never the option table — the table lives on the CATEGORY, and
|
|
29
29
|
* not needing it is the whole point of the projection.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* value
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* `
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
31
|
+
* `formatFeatureValue` resolves an option's copy by looking the value up in
|
|
32
|
+
* `config.options`, so with no table at all it falls through to
|
|
33
|
+
* `String(value)` and the STORAGE SLUG reaches the screen: a live classified
|
|
34
|
+
* deployment printed "Condition: b-u" on its spec rows and a three-slug
|
|
35
|
+
* subtitle on its cards. (The server never had this problem: its own
|
|
36
|
+
* `format_value` has the category's config in hand.)
|
|
37
|
+
*
|
|
38
|
+
* So the split below rebuilds the table, from the best of three sources:
|
|
39
|
+
*
|
|
40
|
+
* 1. The LABEL SNAPSHOT. A `select` DAO written by a stapel-attributes that
|
|
41
|
+
* snapshots labels carries `labels`, a `string[]` positionally aligned
|
|
42
|
+
* with `value` — the same device `ref_select` has always used for its
|
|
43
|
+
* vocabulary terms, and the reason a published listing keeps printing the
|
|
44
|
+
* copy it was published with however the category is edited afterwards.
|
|
45
|
+
* 2. The CATEGORY's own option table, when the display surface has it —
|
|
46
|
+
* `GET /categories/api/v1/categories/{id}/features/` answers with
|
|
47
|
+
* `{"slug":"condition","config":{"type":"select","options":[…]}}`, labels
|
|
48
|
+
* and all. It is handed in through {@link FeatureCopySource}, because a
|
|
49
|
+
* library may not fetch another module's endpoint and a card grid has no
|
|
50
|
+
* category to fetch one FOR.
|
|
51
|
+
* 3. The IDENTITY table — `{value: v, label: v}` — for a value neither of the
|
|
52
|
+
* first two names. That is exactly the table a TRANSLATABLE catalogue
|
|
53
|
+
* would have produced, since its labels ARE its keys, so such a value
|
|
54
|
+
* still reads out of the host's bundle; a NON-translatable one keeps
|
|
55
|
+
* showing the slug. A visible `b-u` gets fixed by rung 1 or 2, and an
|
|
56
|
+
* invented "Used" ships wrong, so the floor stays the value itself.
|
|
57
|
+
*
|
|
58
|
+
* ── Which rung wins, and why that order and not the other one ──────────────
|
|
59
|
+
*
|
|
60
|
+
* The SNAPSHOT outranks the category. The snapshot is what the listing was
|
|
61
|
+
* PUBLISHED with; the category is what the catalogue says today. A category
|
|
62
|
+
* whose option copy was rewritten after a listing went live must not silently
|
|
63
|
+
* restate that listing — the person reading it is reading an advert somebody
|
|
64
|
+
* else wrote, not a form. The category outranks identity, which is the whole
|
|
65
|
+
* point: every listing published before the snapshot release, and every row
|
|
66
|
+
* written by an older server, has no snapshot to outrank it and prints its
|
|
67
|
+
* slug today.
|
|
68
|
+
*
|
|
69
|
+
* A category def that does not describe the STORED row — a different value
|
|
70
|
+
* type, or no such slug — is ignored rather than forced. A category edited
|
|
71
|
+
* from `select` to `string` is a different question, and pairing its options
|
|
72
|
+
* against this row's values would print one question's copy under another
|
|
73
|
+
* question's answer, which does not even LOOK wrong.
|
|
74
|
+
*
|
|
75
|
+
* With no category defs supplied, every rung above 1 is absent and the
|
|
76
|
+
* projection behaves exactly as it did.
|
|
77
|
+
*
|
|
78
|
+
* ── The category path is the REPAIR path, not the mechanism ────────────────
|
|
79
|
+
*
|
|
80
|
+
* The primary mechanism is the server's, and it is the snapshot: stapel-
|
|
81
|
+
* attributes writes `labels` beside a `select`'s codes exactly as it always
|
|
82
|
+
* has for `ref_select`, and stapel-listings ships a reprojection command that
|
|
83
|
+
* back-fills rows written before it. Nothing here competes with that. Rung 2
|
|
84
|
+
* exists for the three states that outlive that deploy:
|
|
85
|
+
*
|
|
86
|
+
* - a row written before the snapshot existed and not yet reprojected;
|
|
87
|
+
* - a deployment that never runs the reprojection;
|
|
88
|
+
* - a category whose option copy was edited AFTER publication — where the
|
|
89
|
+
* snapshot is deliberately the older and correct answer, which is exactly
|
|
90
|
+
* why the precedence runs the way it does above.
|
|
91
|
+
*
|
|
92
|
+
* The resulting table is then read the one way `formatFeatureValue` reads any
|
|
93
|
+
* option table — literal when the config says `translatable_options: false`,
|
|
94
|
+
* through the host's `t` otherwise, where a translatable catalogue's label IS
|
|
95
|
+
* the key and an unknown key comes back unchanged. The flag stays the ROW's:
|
|
96
|
+
* it describes how the listing's own stored config wants its copy resolved,
|
|
97
|
+
* and an unknown key resolves to itself either way.
|
|
47
98
|
*/
|
|
48
99
|
import type { FeatureDef, FeatureValueDto } from "@stapel/attributes-react";
|
|
100
|
+
import { featureType } from "@stapel/attributes-react";
|
|
49
101
|
import type { ListingFeatureDao, ListingFeatureView } from "../api/types.js";
|
|
50
102
|
|
|
51
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Keys that belong to the DAO envelope rather than to the type's config.
|
|
105
|
+
*
|
|
106
|
+
* `labels` is deliberately NOT one of them, even though this module now reads
|
|
107
|
+
* it: `ref_select` and `ref_hierarchical_select` resolve their vocabulary
|
|
108
|
+
* terms from `config.labels` (attributes-react's `refLabels` reads the value
|
|
109
|
+
* envelope first and the config second, and this split hands the formatter a
|
|
110
|
+
* bare `{type, value}`), so envelope-ing the key would blank every term those
|
|
111
|
+
* two types print. For `select` the key is simply inert — its formatter reads
|
|
112
|
+
* `options` and nothing else — so carrying it costs a reference.
|
|
113
|
+
*/
|
|
52
114
|
const ENVELOPE = new Set(["slug", "value", "name", "order", "title", "badge"]);
|
|
53
115
|
|
|
54
|
-
/** The
|
|
55
|
-
*
|
|
56
|
-
*
|
|
116
|
+
/** The type whose stored `value` is a flat list of option keys rather than the
|
|
117
|
+
* things themselves, and whose copy therefore has to be repaired pairwise.
|
|
118
|
+
* `ref_select` is absent because it repairs itself: its `labels` snapshot has
|
|
119
|
+
* always been mandatory (no display package can reach a vocabulary term). */
|
|
57
120
|
const OPTION_VALUED = new Set(["select"]);
|
|
58
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The type whose copy lives in a TREE on the category rather than in a flat
|
|
124
|
+
* table: `hierarchical_select` stores a path of option keys and
|
|
125
|
+
* `formatFeatureValue` walks `config.options` level by level to name each
|
|
126
|
+
* step, so a row without that tree prints "passenger / sedan" — the storage
|
|
127
|
+
* keys — exactly as a table-less `select` prints `b-u`.
|
|
128
|
+
*
|
|
129
|
+
* It is kept apart from {@link OPTION_VALUED} because the two repairs are not
|
|
130
|
+
* the same repair: a flat positional `labels` list cannot describe a tree, so
|
|
131
|
+
* there is nothing to merge and the category's tree is adopted whole or not at
|
|
132
|
+
* all. `formatFeatureValue` already keeps a step the tree does not contain as
|
|
133
|
+
* its raw value, so an adopted tree can only add copy, never blank one out.
|
|
134
|
+
*/
|
|
135
|
+
const TREE_VALUED = new Set(["hierarchical_select"]);
|
|
136
|
+
|
|
59
137
|
/** The canon's `FeatureDef.translate` vocabulary — three values, closed. */
|
|
60
138
|
function isTranslateMode(value: unknown): value is "all" | "title" | "none" {
|
|
61
139
|
return value === "all" || value === "title" || value === "none";
|
|
62
140
|
}
|
|
63
141
|
|
|
64
142
|
/**
|
|
65
|
-
* The
|
|
143
|
+
* The category's feature defs, for a display surface that has them.
|
|
144
|
+
*
|
|
145
|
+
* A detail page reads one listing of one category and its container usually
|
|
146
|
+
* holds that category's schema already (it is the same read the composer is
|
|
147
|
+
* given); a card grid spanning forty categories does not, and passes nothing.
|
|
148
|
+
* Optional for exactly that reason: the seam adds a source of copy, it never
|
|
149
|
+
* becomes a requirement, and a host that wires nothing keeps the behaviour it
|
|
150
|
+
* has.
|
|
151
|
+
*/
|
|
152
|
+
export interface FeatureCopySource {
|
|
153
|
+
/**
|
|
154
|
+
* The chosen category's features, as
|
|
155
|
+
* `GET /categories/api/v1/categories/{id}/features/` answers — the same
|
|
156
|
+
* `readonly FeatureDef[]` `<ListingComposerPage>` already takes. Defs are
|
|
157
|
+
* matched to stored rows by slug AND value type; anything else is ignored.
|
|
158
|
+
*/
|
|
159
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Category defs keyed by slug, or `undefined` when there are none to key —
|
|
163
|
+
* built once per list so a 40-row projection is not 40 linear scans. */
|
|
164
|
+
type CategoryDefs = ReadonlyMap<string, FeatureDef>;
|
|
165
|
+
|
|
166
|
+
function categoryDefs(source: FeatureCopySource): CategoryDefs | undefined {
|
|
167
|
+
const defs = source.categoryFeatures;
|
|
168
|
+
if (defs === undefined || defs.length === 0) return undefined;
|
|
169
|
+
const index = new Map<string, FeatureDef>();
|
|
170
|
+
for (const def of defs) {
|
|
171
|
+
// First wins: a duplicate slug in a category is a catalogue defect, and
|
|
172
|
+
// silently preferring the last one would make the copy depend on the
|
|
173
|
+
// order the endpoint happened to serialize.
|
|
174
|
+
if (typeof def.slug === "string" && def.slug.length > 0 && !index.has(def.slug)) {
|
|
175
|
+
index.set(def.slug, def);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return index.size === 0 ? undefined : index;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The category's definition OF THIS ROW, or `undefined`.
|
|
183
|
+
*
|
|
184
|
+
* Same slug and same value type, or it is not a definition of this row — see
|
|
185
|
+
* the module header on why a mismatch is ignored rather than forced.
|
|
186
|
+
*/
|
|
187
|
+
function categoryDefFor(
|
|
188
|
+
dao: ListingFeatureDao,
|
|
189
|
+
defs: CategoryDefs | undefined
|
|
190
|
+
): FeatureDef | undefined {
|
|
191
|
+
if (defs === undefined) return undefined;
|
|
192
|
+
const def = defs.get(dao.slug);
|
|
193
|
+
if (def === undefined) return undefined;
|
|
194
|
+
return featureType(def) === dao.type ? def : undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A category def's declared option list, when it declares one. */
|
|
198
|
+
function declaredOptions(def: FeatureDef | undefined): readonly unknown[] | undefined {
|
|
199
|
+
const raw = def?.config?.["options"];
|
|
200
|
+
return Array.isArray(raw) ? (raw as readonly unknown[]) : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** One entry of an option list → its `(value, label)` pair, when it is one. */
|
|
204
|
+
function optionPair(entry: unknown): { value: string; label: string } | undefined {
|
|
205
|
+
if (entry === null || typeof entry !== "object") return undefined;
|
|
206
|
+
const option = entry as { value?: unknown; label?: unknown };
|
|
207
|
+
if (typeof option.value !== "string" || option.value.length === 0) return undefined;
|
|
208
|
+
return {
|
|
209
|
+
value: option.value,
|
|
210
|
+
label: typeof option.label === "string" && option.label.length > 0 ? option.label : option.value,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The option table a stored `select` needs and does not carry — see the
|
|
216
|
+
* module header. The category's table underneath, the row's own `labels`
|
|
217
|
+
* snapshot on top, the values themselves as the floor.
|
|
218
|
+
*
|
|
219
|
+
* The alignment rule is the engine's own (`labels if len(labels) ==
|
|
220
|
+
* len(codes) else codes`): a snapshot of a different length than `value` is a
|
|
221
|
+
* snapshot of some other value list, and pairing the two anyway would print
|
|
222
|
+
* one option's copy against another option's value — worse than a slug,
|
|
223
|
+
* because it does not LOOK wrong. So a length mismatch drops the whole
|
|
224
|
+
* snapshot rather than pairing the overlap, and the row falls back to the
|
|
225
|
+
* category (or to identity). Within a usable snapshot, a missing or empty
|
|
226
|
+
* entry falls back to whatever the category, then the value itself, offers for
|
|
227
|
+
* that one pair.
|
|
66
228
|
*
|
|
67
229
|
* Returns `undefined` when there is nothing to add (another type, a config
|
|
68
|
-
* that already carries a table, a value that is not a list of strings
|
|
69
|
-
* common path allocates nothing and a DAO that
|
|
70
|
-
* exactly as it arrived
|
|
230
|
+
* that already carries a table, a value that is not a list of strings and no
|
|
231
|
+
* category table either), so the common path allocates nothing and a DAO that
|
|
232
|
+
* DOES carry options is left exactly as it arrived — a stored table is a
|
|
233
|
+
* write-time snapshot of the whole question, which is a stronger statement
|
|
234
|
+
* about this listing than either the per-value snapshot or today's category.
|
|
71
235
|
*/
|
|
72
|
-
function
|
|
236
|
+
function selectOptions(
|
|
73
237
|
dao: ListingFeatureDao,
|
|
74
|
-
config: Readonly<Record<string, unknown
|
|
238
|
+
config: Readonly<Record<string, unknown>>,
|
|
239
|
+
categoryDef: FeatureDef | undefined
|
|
75
240
|
): readonly { value: string; label: string }[] | undefined {
|
|
76
241
|
if (typeof dao.type !== "string" || !OPTION_VALUED.has(dao.type)) return undefined;
|
|
77
242
|
if (Array.isArray(config["options"])) return undefined;
|
|
78
|
-
const raw = Array.isArray(dao.value)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
243
|
+
const raw: readonly unknown[] = Array.isArray(dao.value)
|
|
244
|
+
? (dao.value as readonly unknown[])
|
|
245
|
+
: [dao.value];
|
|
246
|
+
const snapshot: unknown = dao.labels;
|
|
247
|
+
const labels: readonly unknown[] | undefined =
|
|
248
|
+
Array.isArray(snapshot) && snapshot.length === raw.length
|
|
249
|
+
? (snapshot as readonly unknown[])
|
|
250
|
+
: undefined;
|
|
251
|
+
|
|
252
|
+
// A Map so the three rungs can overwrite one another by value instead of
|
|
253
|
+
// the reader having to scan a list that carries the same value twice —
|
|
254
|
+
// `labelOf` takes the FIRST match, so a duplicate would make the losing
|
|
255
|
+
// rung win.
|
|
256
|
+
const table = new Map<string, string>();
|
|
257
|
+
for (const entry of declaredOptions(categoryDef) ?? []) {
|
|
258
|
+
const pair = optionPair(entry);
|
|
259
|
+
if (pair !== undefined) table.set(pair.value, pair.label);
|
|
260
|
+
}
|
|
261
|
+
raw.forEach((value, index) => {
|
|
262
|
+
if (typeof value !== "string") return;
|
|
263
|
+
const label = labels?.[index];
|
|
264
|
+
if (typeof label === "string" && label.length > 0) table.set(value, label);
|
|
265
|
+
else if (!table.has(value)) table.set(value, value);
|
|
266
|
+
});
|
|
267
|
+
if (table.size === 0) return undefined;
|
|
268
|
+
return [...table].map(([value, label]) => ({ value, label }));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The category's option TREE, adopted whole for a `hierarchical_select` that
|
|
273
|
+
* stored none — see {@link TREE_VALUED}.
|
|
274
|
+
*
|
|
275
|
+
* Nothing is merged and nothing is rewritten: the tree is the category's
|
|
276
|
+
* structure, and this projection has no per-level snapshot with which to
|
|
277
|
+
* disagree about it.
|
|
278
|
+
*/
|
|
279
|
+
function adoptedTree(
|
|
280
|
+
dao: ListingFeatureDao,
|
|
281
|
+
config: Readonly<Record<string, unknown>>,
|
|
282
|
+
categoryDef: FeatureDef | undefined
|
|
283
|
+
): readonly unknown[] | undefined {
|
|
284
|
+
if (typeof dao.type !== "string" || !TREE_VALUED.has(dao.type)) return undefined;
|
|
285
|
+
if (Array.isArray(config["options"])) return undefined;
|
|
286
|
+
return declaredOptions(categoryDef);
|
|
82
287
|
}
|
|
83
288
|
|
|
84
289
|
/**
|
|
@@ -90,7 +295,16 @@ function synthesizedOptions(
|
|
|
90
295
|
* position that changes whenever the category does.
|
|
91
296
|
*/
|
|
92
297
|
export function featureFromDao(
|
|
93
|
-
dao: ListingFeatureDao
|
|
298
|
+
dao: ListingFeatureDao,
|
|
299
|
+
source: FeatureCopySource = {}
|
|
300
|
+
): ListingFeatureView | undefined {
|
|
301
|
+
return featureView(dao, categoryDefs(source));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The work of {@link featureFromDao}, against an already-built index. */
|
|
305
|
+
function featureView(
|
|
306
|
+
dao: ListingFeatureDao,
|
|
307
|
+
defs: CategoryDefs | undefined
|
|
94
308
|
): ListingFeatureView | undefined {
|
|
95
309
|
if (typeof dao.slug !== "string" || dao.slug.length === 0) return undefined;
|
|
96
310
|
|
|
@@ -98,7 +312,9 @@ export function featureFromDao(
|
|
|
98
312
|
for (const [key, value] of Object.entries(dao)) {
|
|
99
313
|
if (!ENVELOPE.has(key)) config[key] = value;
|
|
100
314
|
}
|
|
101
|
-
const
|
|
315
|
+
const categoryDef = categoryDefFor(dao, defs);
|
|
316
|
+
const options =
|
|
317
|
+
selectOptions(dao, config, categoryDef) ?? adoptedTree(dao, config, categoryDef);
|
|
102
318
|
if (options !== undefined) config["options"] = options;
|
|
103
319
|
|
|
104
320
|
const feature: FeatureDef = {
|
|
@@ -125,11 +341,13 @@ export function featureFromDao(
|
|
|
125
341
|
* unreadableFeatureCount} so a skin can say how many rather than pretend the
|
|
126
342
|
* listing had fewer attributes. */
|
|
127
343
|
export function featuresFromDaoList(
|
|
128
|
-
daos: readonly ListingFeatureDao[] | null | undefined
|
|
344
|
+
daos: readonly ListingFeatureDao[] | null | undefined,
|
|
345
|
+
source: FeatureCopySource = {}
|
|
129
346
|
): readonly ListingFeatureView[] {
|
|
347
|
+
const defs = categoryDefs(source);
|
|
130
348
|
const out: ListingFeatureView[] = [];
|
|
131
349
|
for (const dao of daos ?? []) {
|
|
132
|
-
const view =
|
|
350
|
+
const view = featureView(dao, defs);
|
|
133
351
|
if (view !== undefined) out.push(view);
|
|
134
352
|
}
|
|
135
353
|
return out;
|
package/src/model/validation.ts
CHANGED
|
@@ -93,6 +93,51 @@ export const TITLE_FIELD = "title";
|
|
|
93
93
|
export const PRICE_FIELD = "price";
|
|
94
94
|
export const IMAGES_FIELD = "images";
|
|
95
95
|
export const CATEGORY_FIELD = "category_id";
|
|
96
|
+
/** The coordinate pair, as one control: a latitude alone is not a place. */
|
|
97
|
+
export const LOCATION_FIELD = "location";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The composer's OWN controls, in the order the draft declares them.
|
|
101
|
+
*
|
|
102
|
+
* The mirror is a `Record`, and a record has no order worth trusting — its
|
|
103
|
+
* feature refusals happen to come first because of a spread. "Which field is
|
|
104
|
+
* the first unsatisfied one?" is a question a person asks about a FORM, so the
|
|
105
|
+
* answer is written down as a list rather than read off an object: the
|
|
106
|
+
* category (nothing else can be answered until it is chosen), then the
|
|
107
|
+
* listing's own fields, then — appended by the caller — the category's
|
|
108
|
+
* features in the order the schema declares them.
|
|
109
|
+
*/
|
|
110
|
+
export const LISTING_FIELD_ORDER: readonly string[] = [
|
|
111
|
+
CATEGORY_FIELD,
|
|
112
|
+
TITLE_FIELD,
|
|
113
|
+
DESCRIPTION_FIELD,
|
|
114
|
+
PRICE_FIELD,
|
|
115
|
+
LOCATION_FIELD,
|
|
116
|
+
IMAGES_FIELD,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The API's own field names → the control that holds them.
|
|
121
|
+
*
|
|
122
|
+
* The draft serializer writes `*_draft` columns, and the two coordinates are
|
|
123
|
+
* one field on screen. Without this table a per-field 400 has nowhere to land
|
|
124
|
+
* and becomes a banner nobody can act on — which is the whole of blocker C2's
|
|
125
|
+
* second half: a save refused for `lat_draft` painted "Validation error" over
|
|
126
|
+
* the footer while the location control, the only thing the person could have
|
|
127
|
+
* changed, stayed clean.
|
|
128
|
+
*/
|
|
129
|
+
const CONTROL_OF_API_FIELD: Readonly<Record<string, string>> = {
|
|
130
|
+
title_draft: TITLE_FIELD,
|
|
131
|
+
description_draft: DESCRIPTION_FIELD,
|
|
132
|
+
price_draft: PRICE_FIELD,
|
|
133
|
+
images_draft: IMAGES_FIELD,
|
|
134
|
+
category_id: CATEGORY_FIELD,
|
|
135
|
+
lat_draft: LOCATION_FIELD,
|
|
136
|
+
lon_draft: LOCATION_FIELD,
|
|
137
|
+
location_id_draft: LOCATION_FIELD,
|
|
138
|
+
location_label_draft: LOCATION_FIELD,
|
|
139
|
+
stock_quantity: "stockQuantity",
|
|
140
|
+
};
|
|
96
141
|
|
|
97
142
|
/** A client-side refusal. `status: 0` on purpose: a rule this build applied
|
|
98
143
|
* must never be indistinguishable from one that came over the wire. */
|
|
@@ -162,7 +207,7 @@ export function mirrorListingFields(
|
|
|
162
207
|
const hasLat = lat !== null && lat.length > 0;
|
|
163
208
|
const hasLon = lon !== null && lon.length > 0;
|
|
164
209
|
if (hasLat !== hasLon) {
|
|
165
|
-
out[
|
|
210
|
+
out[LOCATION_FIELD] = mirrored(LISTINGS_I18N_KEYS.composeGeoIncomplete);
|
|
166
211
|
}
|
|
167
212
|
|
|
168
213
|
return out;
|
|
@@ -244,6 +289,44 @@ export function publishRefusal(thrown: unknown): PublishRefusal {
|
|
|
244
289
|
};
|
|
245
290
|
}
|
|
246
291
|
|
|
292
|
+
/**
|
|
293
|
+
* A per-field refusal in the ORDINARY envelope → the control it belongs to.
|
|
294
|
+
*
|
|
295
|
+
* `save-draft` and `create` do not answer a batch: DRF's field validation
|
|
296
|
+
* raises, and stapel-core's handler folds it into one envelope carrying
|
|
297
|
+
* `params.field` — the field name, and often the ONLY thing in the response
|
|
298
|
+
* that says what went wrong, because a DRF code the registry does not know
|
|
299
|
+
* (`max_decimal_places`, say) collapses to the generic
|
|
300
|
+
* `error.400.validation_error`, whose sentence is "Validation error" and
|
|
301
|
+
* nothing else.
|
|
302
|
+
*
|
|
303
|
+
* So the field name is the payload. Put on the control, it is the difference
|
|
304
|
+
* between a red banner the person cannot act on and a red field they can.
|
|
305
|
+
* Returns `{}` for anything that names no field, or names one this composer
|
|
306
|
+
* does not draw — a banner is right for those, and a refusal routed to a
|
|
307
|
+
* control that does not exist would vanish.
|
|
308
|
+
*/
|
|
309
|
+
export function envelopeFieldErrors(
|
|
310
|
+
thrown: unknown
|
|
311
|
+
): Readonly<Record<string, FlowError>> {
|
|
312
|
+
if (!isStapelApiError(thrown)) return {};
|
|
313
|
+
const named = thrown.params["field"];
|
|
314
|
+
if (typeof named !== "string" || named.length === 0) return {};
|
|
315
|
+
const control = CONTROL_OF_API_FIELD[named];
|
|
316
|
+
if (control === undefined) return {};
|
|
317
|
+
return {
|
|
318
|
+
[control]: {
|
|
319
|
+
code: thrown.code,
|
|
320
|
+
// `field` is the fleet's routing param; the control key is what a
|
|
321
|
+
// sentence should name, not the column the server happened to use.
|
|
322
|
+
params: { ...thrown.params, field: control },
|
|
323
|
+
status: thrown.status,
|
|
324
|
+
message: thrown.message,
|
|
325
|
+
language: thrown.language,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
247
330
|
/**
|
|
248
331
|
* A server batch → refusals keyed by CONTROL.
|
|
249
332
|
*
|
package/src/nav/manifest.ts
CHANGED
|
@@ -60,6 +60,7 @@ export const navEntries: readonly NavEntry[] = [
|
|
|
60
60
|
{
|
|
61
61
|
id: "listings.compose",
|
|
62
62
|
labelKey: "listings.nav.compose",
|
|
63
|
+
shortLabelKey: "listings.nav.compose.short",
|
|
63
64
|
icon: "PlusOutlined",
|
|
64
65
|
route: { path: "/new" },
|
|
65
66
|
component: { export: "ListingComposerPage", subpath: "default" },
|
|
@@ -73,6 +74,7 @@ export const navEntries: readonly NavEntry[] = [
|
|
|
73
74
|
// Relative path: a child of the container's `/account` layout route.
|
|
74
75
|
id: "listings.mine",
|
|
75
76
|
labelKey: "listings.nav.mine",
|
|
77
|
+
shortLabelKey: "listings.nav.mine.short",
|
|
76
78
|
icon: "ProfileOutlined",
|
|
77
79
|
route: { path: "listings" },
|
|
78
80
|
component: { export: "MyListingsPane", subpath: "default" },
|