|
34 | 34 | * objects.<name>._views.<view>.label |
35 | 35 | * objects.<name>._views.<view>.description |
36 | 36 | * objects.<name>._views.<view>.emptyState.title / .message |
| 37 | + * objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText |
| 38 | + * / .confirmLabel |
| 39 | + * objects.<name>._views.<view>.bulkActions.<def>.params.<param>.label |
| 40 | + * / .help / .placeholder |
| 41 | + * ^ a bulk param spells its hint `help`; an ACTION param spells the same |
| 42 | + * idea `helpText` (`ui/bulk-action.zod.ts`'s known divergence) |
| 43 | + * objects.<name>._validations.<rule>.message |
37 | 44 | * objects.<name>._actions.<action>.label |
38 | 45 | * objects.<name>._actions.<action>.description |
39 | 46 | * objects.<name>._actions.<action>.confirmText |
|
48 | 55 | * apps.<app>.navigation.<id>.label |
49 | 56 | * dashboards.<dash>.label / .description |
50 | 57 | * dashboards.<dash>.widgets.<w>.title / .description |
| 58 | + * datasets.<dataset>.label / .description |
| 59 | + * datasets.<dataset>.dimensions.<dim>.label |
| 60 | + * datasets.<dataset>.measures.<measure>.label |
51 | 61 | * pages.<page>.label / .description |
52 | 62 | * pages.<page>.title / .subtitle (from the page's `page:header` component) |
53 | 63 | * pages.<page>.components.<id>.<key> (per-component copy, #6080) |
@@ -125,6 +135,7 @@ export interface ExpectedEntry { |
125 | 135 | | 'navigation' |
126 | 136 | | 'dashboard' |
127 | 137 | | 'widget' |
| 138 | + | 'dataset' |
128 | 139 | | 'page' |
129 | 140 | | 'flow' |
130 | 141 | | 'metadataType' |
@@ -274,6 +285,81 @@ function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: str |
274 | 285 | pushDerived(out, [...root, 'label'], view?.label ?? viewName, inlineText(view?.label), 'view', { objectName }); |
275 | 286 | pushOptional(out, [...root, 'description'], view?.description, 'view', { objectName }); |
276 | 287 | pushViewEmptyState(out, root, view, objectName); |
| 288 | + pushBulkActionDefs(out, root, view, objectName); |
| 289 | +} |
| 290 | + |
| 291 | +/** |
| 292 | + * Emit `_views.<view>.bulkActions.<def>.*` for a list view's authored |
| 293 | + * `bulkActionDefs[]` (#14253's resolver, #14376's walk). |
| 294 | + * |
| 295 | + * **Why this hangs off the VIEW and not the action pass.** A `bulkActionDefs` |
| 296 | + * entry is authored inside the view and is not an action document, so it never |
| 297 | + * reaches `translateAction` and no other pass here would ever see it. That is |
| 298 | + * the same reason `translateView` — not `translateAction` — is where the |
| 299 | + * resolver overlays it, and the reason `ObjectTranslationDataSchema` puts the |
| 300 | + * group under `_views.<view>` rather than beside `_actions`. |
| 301 | + * |
| 302 | + * **The view key is the caller's, deliberately.** `translateBulkActionDefs` is |
| 303 | + * called by `translateView` with `viewTranslationKey(view, objectName)` — the |
| 304 | + * bare `_views` key — so emitting under the same `root` this function already |
| 305 | + * built for `label` / `description` keeps the two halves keyed by construction |
| 306 | + * rather than by a second derivation (the #5164 lesson one surface over). |
| 307 | + * |
| 308 | + * **Read from the AUTHORED address.** The resolver reads |
| 309 | + * `config.bulkActionDefs` because a SERVED `ViewItem` nests the whole |
| 310 | + * `ListViewSchema` under `config`; this walker is handed the authored stack |
| 311 | + * config, where the defs sit on the list view itself — the same authored |
| 312 | + * addresses the rest of this file reads (`view.list.data.object`, |
| 313 | + * `obj.listViews`). Accepting the served spelling here as well would be a |
| 314 | + * tolerant alias for a shape this walk is never given. |
| 315 | + * |
| 316 | + * Three deliberate exclusions, each measured against `BulkActionDefSchema` |
| 317 | + * rather than mirrored from the report: |
| 318 | + * |
| 319 | + * - `successMessage` — a def declares none (the run reports a per-record |
| 320 | + * outcome summary the console words from its own catalog); |
| 321 | + * - `description` — a def declares none either; the sentence above the |
| 322 | + * affected-record summary IS `confirmText`; |
| 323 | + * - per-param `options` — `BulkActionParamTranslationSchema` carries |
| 324 | + * `guidance` against them instead of a key, so scaffolding them would |
| 325 | + * write keys `.strict()` then rejects. |
| 326 | + * |
| 327 | + * ⚠️ `help`, not `helpText`. A bulk param spells its hint `help` |
| 328 | + * (`BulkActionParamSchema.help`) where an ACTION param spells it `helpText` — |
| 329 | + * the known divergence `ui/bulk-action.zod.ts` names, and the one spelling the |
| 330 | + * translation face declares. |
| 331 | + */ |
| 332 | +function pushBulkActionDefs(out: ExpectedEntry[], viewRoot: string[], view: any, objectName: string): void { |
| 333 | + const defs = view?.bulkActionDefs; |
| 334 | + if (!Array.isArray(defs)) return; |
| 335 | + for (const def of defs) { |
| 336 | + if (!def || typeof def !== 'object') continue; |
| 337 | + const defName = def.name; |
| 338 | + if (typeof defName !== 'string' || defName.length === 0) continue; |
| 339 | + const base = [...viewRoot, 'bulkActions', defName]; |
| 340 | + // The selection bar renders `def.label ?? formatActionLabel(def.name)` |
| 341 | + // (objectui `BulkActionBar.tsx`), so the humanized name is what a reader |
| 342 | + // actually sees when the author omitted a label — a usable seed, with |
| 343 | + // `inline` left unset so coverage never demands a translation of a string |
| 344 | + // nobody wrote. |
| 345 | + const authoredLabel = inlineText(def.label); |
| 346 | + pushDerived(out, [...base, 'label'], authoredLabel ?? humanizeFieldPath(defName), authoredLabel, 'view', { objectName }); |
| 347 | + pushOptional(out, [...base, 'confirmText'], def.confirmText, 'view', { objectName }); |
| 348 | + pushOptional(out, [...base, 'confirmLabel'], def.confirmLabel, 'view', { objectName }); |
| 349 | + if (!Array.isArray(def.params)) continue; |
| 350 | + for (const param of def.params) { |
| 351 | + if (!param || typeof param !== 'object') continue; |
| 352 | + const pname = param.name; |
| 353 | + if (typeof pname !== 'string' || pname.length === 0) continue; |
| 354 | + const pbase = [...base, 'params', pname]; |
| 355 | + // The dialog renders `param.label ?? param.name` — the bare name, the |
| 356 | + // same fallback `pushActionParams` seeds an inline action param from. |
| 357 | + const literalLabel = inlineText(param.label); |
| 358 | + pushDerived(out, [...pbase, 'label'], literalLabel ?? pname, literalLabel, 'view', { objectName }); |
| 359 | + pushOptional(out, [...pbase, 'help'], param.help, 'view', { objectName }); |
| 360 | + pushOptional(out, [...pbase, 'placeholder'], param.placeholder, 'view', { objectName }); |
| 361 | + } |
| 362 | + } |
277 | 363 | } |
278 | 364 |
|
279 | 365 | /** |
@@ -424,6 +510,65 @@ function pushActionResultDialog( |
424 | 510 | } |
425 | 511 | } |
426 | 512 |
|
| 513 | +/** |
| 514 | + * How deep a `conditional` chain is followed. `ValidationRuleSchema` is |
| 515 | + * recursive with no declared bound, and this walker is handed hand-authored |
| 516 | + * TypeScript — a shared branch object appearing under its own ancestor would |
| 517 | + * otherwise loop forever. Real nesting is two or three deep (the schema's own |
| 518 | + * worked examples stop at two). |
| 519 | + */ |
| 520 | +const MAX_VALIDATION_DEPTH = 10; |
| 521 | + |
| 522 | +/** |
| 523 | + * Emit `objects.<object>._validations.<rule>.message` for an object's custom |
| 524 | + * validation rules (#14253's resolver, #14376's walk). |
| 525 | + * |
| 526 | + * `object.validations[].message` is the sentence a rejected write returns, and |
| 527 | + * the ObjectQL rule evaluator now resolves it through the engine's existing |
| 528 | + * `i18nService` channel at exactly this address |
| 529 | + * (`objectValidationMessageKey`, `spec/system/i18n-resolver.ts`). Without this |
| 530 | + * pass the address has a reader and a schema slot but nothing writes the |
| 531 | + * skeleton, so a deployment gets platform-generated refusals in the caller's |
| 532 | + * language and author-written ones in the source language, side by side in one |
| 533 | + * error envelope. |
| 534 | + * |
| 535 | + * **A `conditional` wrapper contributes no key of its own.** `checkConditional` |
| 536 | + * evaluates `when` and then returns `evaluateRule(branch, …)` — the BRANCH |
| 537 | + * supplies the violation, so the wrapper's own `message` never reaches a user. |
| 538 | + * Scaffolding it would offer a translator a string no rejected write can ever |
| 539 | + * show. The branches carry their own `name` and are addressed by it, which is |
| 540 | + * what both the resolver's JSDoc and `_validations`' schema note state. |
| 541 | + * |
| 542 | + * **`active: false` is not a reason to skip a rule.** It is a toggle on a |
| 543 | + * surface that exists, not the absence of one, and no other family in this |
| 544 | + * walker consults a runtime toggle — this walk reports what a config |
| 545 | + * DECLARES. Flipping the toggle back on must not silently owe a translation. |
| 546 | + */ |
| 547 | +function pushValidationMessages( |
| 548 | + out: ExpectedEntry[], |
| 549 | + objectName: string, |
| 550 | + rules: unknown, |
| 551 | + depth: number, |
| 552 | +): void { |
| 553 | + if (!Array.isArray(rules) || depth >= MAX_VALIDATION_DEPTH) return; |
| 554 | + for (const rule of rules) { |
| 555 | + if (!rule || typeof rule !== 'object') continue; |
| 556 | + const ruleName = (rule as any).name; |
| 557 | + if (typeof ruleName !== 'string' || ruleName.length === 0) continue; |
| 558 | + if ((rule as any).type === 'conditional') { |
| 559 | + pushValidationMessages(out, objectName, [(rule as any).then, (rule as any).otherwise], depth + 1); |
| 560 | + continue; |
| 561 | + } |
| 562 | + pushEntry( |
| 563 | + out, |
| 564 | + ['objects', objectName, '_validations', ruleName, 'message'], |
| 565 | + inlineText((rule as any).message), |
| 566 | + 'object', |
| 567 | + { objectName }, |
| 568 | + ); |
| 569 | + } |
| 570 | +} |
| 571 | + |
427 | 572 | // ─── Object sections (`objects.<o>._sections.<section>.label`) ───────── |
428 | 573 | // |
429 | 574 | // A section heading is authored in TWO independent places and rendered from |
@@ -862,6 +1007,9 @@ export function collectExpectedEntries( |
862 | 1007 | pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); |
863 | 1008 | } |
864 | 1009 | } |
| 1010 | + |
| 1011 | + // Custom validation-rule rejection messages (`_validations.<rule>.message`). |
| 1012 | + pushValidationMessages(out, objectName, obj.validations, 0); |
865 | 1013 | } |
866 | 1014 |
|
867 | 1015 | // ── Top-level views ────────────────────────────────────────────── |
@@ -974,6 +1122,9 @@ export function collectExpectedEntries( |
974 | 1122 | } |
975 | 1123 | } |
976 | 1124 |
|
| 1125 | + // ── Analytics datasets (`datasets.<name>.…`) ───────────────────── |
| 1126 | + walkDatasets(config, out); |
| 1127 | + |
977 | 1128 | // ── Pages + their `page:header` copy ────────────────────────────── |
978 | 1129 | const pages: any[] = Array.isArray(config?.pages) ? config.pages : []; |
979 | 1130 | for (const page of pages) { |
@@ -1041,6 +1192,63 @@ export function collectExpectedEntries( |
1041 | 1192 | return out.filter((entry) => !warnedGroups.has(entry.path[0])); |
1042 | 1193 | } |
1043 | 1194 |
|
| 1195 | +// ─── Analytics datasets (`datasets.<name>.…`) ────────────────────────── |
| 1196 | + |
| 1197 | +/** |
| 1198 | + * Emit the dataset copy surface (#14253's resolver, #14376's walk): |
| 1199 | + * |
| 1200 | + * datasets.<name>.label |
| 1201 | + * datasets.<name>.description |
| 1202 | + * datasets.<name>.dimensions.<dimension>.label |
| 1203 | + * datasets.<name>.measures.<measure>.label |
| 1204 | + * |
| 1205 | + * **Why a dataset is a display surface at all.** It reads like a back-office |
| 1206 | + * definition, but a measure label is drawn ON THE DASHBOARD — under every |
| 1207 | + * metric tile and on every chart axis. `translateDataset` is registered in |
| 1208 | + * `METADATA_DOCUMENT_TRANSLATORS`, so a served dataset is already localized at |
| 1209 | + * the REST boundary; this pass is the half that writes the skeleton. |
| 1210 | + * |
| 1211 | + * **Top level, not under `dashboards`.** A dataset is the one definition every |
| 1212 | + * presentation binds to BY REFERENCE (ADR-0021 D1): the same measure is drawn |
| 1213 | + * by N widgets across M dashboards, so addressing it under a dashboard would |
| 1214 | + * ask for the same string once per presentation and leave a dataset no |
| 1215 | + * dashboard references unaddressable. |
| 1216 | + * |
| 1217 | + * **`pushOptional`, not `pushDerived`, for every key here.** These four are |
| 1218 | + * `I18nLabelSchema` at the authoring site, so a value may already be an inline |
| 1219 | + * `{ en, 'zh-CN' }` map (#5728) — not source text to scaffold from, and |
| 1220 | + * `inlineText` narrows it away. And no renderer fallback is measured for a |
| 1221 | + * member that declares no `label` at all, so there is no reader-visible string |
| 1222 | + * to seed one from: recording the key without an `inline` keeps the coverage |
| 1223 | + * gate quiet about a string nobody wrote while still noticing a bundle that |
| 1224 | + * authors it. It is the same posture the resolver takes — `translateDataset` |
| 1225 | + * writes only where the bundle answered. |
| 1226 | + * |
| 1227 | + * The face stops at `label` below the dataset: `DatasetDimensionSchema` and |
| 1228 | + * `DatasetMeasureSchema` declare no `description` and say so in their own |
| 1229 | + * authoring guidance, so a `dimensions.<d>.description` key would parse clean |
| 1230 | + * and translate nothing. |
| 1231 | + */ |
| 1232 | +function walkDatasets(config: any, out: ExpectedEntry[]): void { |
| 1233 | + const datasets: any[] = Array.isArray(config?.datasets) ? config.datasets : []; |
| 1234 | + for (const dataset of datasets) { |
| 1235 | + if (!dataset || typeof dataset !== 'object') continue; |
| 1236 | + const name = dataset.name; |
| 1237 | + if (typeof name !== 'string' || name.length === 0) continue; |
| 1238 | + pushOptional(out, ['datasets', name, 'label'], dataset.label, 'dataset'); |
| 1239 | + pushOptional(out, ['datasets', name, 'description'], dataset.description, 'dataset'); |
| 1240 | + for (const group of ['dimensions', 'measures'] as const) { |
| 1241 | + const members: any[] = Array.isArray(dataset[group]) ? dataset[group] : []; |
| 1242 | + for (const member of members) { |
| 1243 | + if (!member || typeof member !== 'object') continue; |
| 1244 | + const memberName = member.name; |
| 1245 | + if (typeof memberName !== 'string' || memberName.length === 0) continue; |
| 1246 | + pushOptional(out, ['datasets', name, group, memberName, 'label'], member.label, 'dataset'); |
| 1247 | + } |
| 1248 | + } |
| 1249 | + } |
| 1250 | +} |
| 1251 | + |
1044 | 1252 | // ─── Screen flows (`flows.<flow>.screens.<node_id>.…`) ───────────────── |
1045 | 1253 |
|
1046 | 1254 | /** |
|
0 commit comments