Skip to content

Commit 6535c19

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-27565-query-syntax-projection
2 parents 8012d0c + 1ed3360 commit 6535c19

80 files changed

Lines changed: 3139 additions & 88 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/i18n': patch
4+
'@object-ui/fields': patch
5+
'@object-ui/components': patch
6+
'@object-ui/plugin-form': patch
7+
'@object-ui/plugin-grid': patch
8+
'@object-ui/plugin-list': patch
9+
'@object-ui/plugin-dashboard': patch
10+
'@object-ui/plugin-report': patch
11+
'@object-ui/plugin-detail': patch
12+
'@object-ui/plugin-gantt': patch
13+
'@object-ui/app-shell': patch
14+
---
15+
16+
feat(ui): a `record`-mode currency amount shows each record's own currency; the code field is an ISO 4217 select pre-filled with the organization currency; Studio edits the mode and the pointer (ADR-0144 D2, D6, D8 rule 4)
17+
18+
Clause-②: no
19+
Metadata-protocol: none
20+
21+
An amount declaring `currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }` now renders in the currency its record's `currency_code` holds, on every face that holds the record:
22+
23+
- **Cells and widgets.** `resolveFieldCurrency(field, tenantDefault, record)` takes the record in scope as an optional third argument. Under `record` mode it reads only that record's code field; no record, or a blank code, resolves to no currency, so the amount shows as a plain number and never in the tenant default. `dynamic` and `fixed` fields resolve exactly as before. The list cell, the form and inline amount box (symbol, decimals and spinner step follow the selected code), formula cells returning `currency`, the record page's summary chips, the dashboard table and record drawer, and Gantt tooltips all pass the record. `recordCurrencyField(field)` names the pointer.
24+
- **The code field.** A `text` field declaring `valueDomain: 'iso_4217_currency'` renders a searchable select instead of a free-text box, grouped "Available" (the organization currency, then rated currencies, then the codes the object's records already hold) and "All currencies (ISO 4217)". It offers only codes the value domain accepts. The form reads the used codes once per object, with one grouped query, and hands them to the field on its own metadata (`usedCurrencyCodes`); a data source without grouped queries offers home + the record's own code. The rated group is empty until the exchange-rate table ships. The picker itself is `CurrencyCodePicker` in `@object-ui/components`.
25+
- **Filters.** In the list's filter builder, a condition on a `record`-mode amount gains a required currency picker, defaulting to the organization currency, and the filter reads `amount op X AND code = C`: the code condition is a second row of the same group, so stored filters keep their shape. A group holding such a pair stays on AND (under OR the pair would split), and a stored OR group says so. Presence checks (`is null`, `is empty`, …) need no currency.
26+
- **Create forms.** A code field that a `record`-mode amount points at opens pre-filled with `localization.currency`, as a value the user submits; a declared `defaultValue` and the caller's record win, and nothing is seeded while the setting is unset.
27+
- **Totals.** The grid footer draws no sum, average, minimum or maximum for a `record`-mode amount (its rows are in different currencies); counts stand. A group header keeps those aggregates only when the grid is grouped by the amount's code field. Consolidated totals belong to the home field, a `dynamic` amount that totals as before.
28+
- **Dataset answers.** A grouped analytics answer over a `record`-mode amount marks its measure column with `currencyField`, the result column holding each row's code. The dashboard `table` widget, the report table (and its export) and the Studio dataset preview format each row in that row's currency; a row without a code, and the totals row, show a plain number. `measureCellCurrency(field, row)` in `@object-ui/core` is the one read. Columns without `currencyField` format exactly as before.
29+
- **Studio.** A currency field (or a formula returning `currency`) gains a currency section: the mode (dynamic, fixed, record), the fixed code, and under `record` a `currencyField` picker listing the object's ISO 4217 code fields, with "Create currency field", which adds `currency_code` (`text`, `valueDomain: 'iso_4217_currency'`, required) and points the amount at it.
30+
31+
⛔ Not released alone: this ships in the same release as `currencyMode: 'record'` itself and its aggregate refusals.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/types': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(types, console): a shared `record`-mode currency amount shows its record's own currency (ADR-0144 D2, D9)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The share-link resolve answer's field projection (`buildShareLinkResolveAnswer`, `@objectstack/types`) carried a currency field's `currencyMode` and `defaultCurrency` only, so a shared `record`-mode amount, whose currency lives in a sibling code field, rendered as a plain number. A currency field's projected `currencyConfig` now also carries `currencyField`, the amount's code-field pointer, when the answer's `record` carries that code field. A link that redacts the code field gets neither the code nor the pointer, so the answer still describes nothing the record hides, and the amount shows as a plain number.
12+
13+
The shared-record page hands the shared record to the currency face, which reads the code from it, so the amount shows in the currency its own record names. Every other field and every other key of the answer is unchanged.
14+
15+
⛔ Not released alone: this ships in the same release as `currencyMode: 'record'` itself.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
fix(i18n): a tenant's rewrite of a packaged validation rule message is served as written in every locale, on the refusal and on `/meta` (#27221)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: semantic
10+
11+
A package's bundle translates the rule messages that package shipped (`objects.<object>._validations.<rule>.message`). When a tenant overlaid a packaged object and reworded one of its validation rule messages, keeping the rule's name, a reader in any locale the bundle carries got the package's translation instead of the tenant's sentence. The rejected write's error and the rule served by `/meta` (and the public form) both showed it.
12+
13+
- **Now.** Both readers apply the bundle entry only while the served message still equals the packaged rule's message of the same name. A message that differs is the tenant's own and is served and returned as written, in every locale. Rules the tenant left alone are still translated. A `conditional` branch is matched by its own name. A rule the owning package does not declare (one an `objectExtensions` entry adds, or one the tenant added) has no packaged message to differ from and is still translated under its own name, as before. With no packaged owner (an object authored entirely at runtime), nothing changes.
14+
- **One rule, both readers.** `/meta` (`translateObject`) and the rule evaluator use the same comparison, the one already applied to an object's `label`, `description`, embedded list views and fields. `/meta` and the refusal of the same write still show one sentence for one rule and locale.
15+
- **`scalarOverridesPackagedBase`** (`@objectstack/spec/system`) now also accepts the key `'message'`, so the rule evaluator judges a rule message through it. Its behaviour for the existing keys is unchanged.
16+
- **`evaluateValidationRules`'s `messages` option** (`EvaluateRulesOptions.messages`, `@objectstack/objectql`) gains an optional `packagedRule` lookup, which the engine fills from the object's packaged owner. A caller that passes its own `messages` without it keeps the previous behaviour.

‎content/docs/ui/translations.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,19 @@ Objects follow the same rule for their `label`, `pluralLabel` and
223223
label an [object extension](/docs/data-modeling/object-extensions) contributes,
224224
for one) is served as written.
225225

226+
An object's validation rule messages follow it too, on both places a reader
227+
sees them: the error a rejected write returns, and the rule served with the
228+
object (`/meta`, and the forms built from it). The rule's `message` is compared
229+
with the message of the rule of the same `name` in the owning package's
230+
declaration, a `conditional` rule's `then` / `otherwise` branch by its own name.
231+
An organization that overlays a packaged object and rewords a rule's message,
232+
keeping its name, gets that sentence as written in every locale; the rules it
233+
left alone are still translated, and both places always show the same sentence.
234+
Unlike a widget or a bulk action, a rule the owning package does not declare
235+
(one an object extension adds, or one the organization added) is **still
236+
translated** under its own name, `objects.<name>._validations.<rule>.message`:
237+
there is no shipped message for it to differ from.
238+
226239
## Organizing the files
227240

228241
How you lay out translation source files is an authoring convention — your

‎packages/objectql/src/engine-validation-messages.ts‎

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@
3333
* closure, which runs after `validationMessageContext` has returned and sees
3434
* whatever service `setI18nService` has installed by then, as it did.
3535
* `setI18nService` itself stays in the class: it is the field's setter.
36+
* `registry` (ADR-0029 D9.2a, the packaged rule lookup) goes as a getter too.
3637
*
3738
* This module imports the engine only as a TYPE, so there is no module cycle
3839
* at run time, and nothing re-exports it, so the published surface does not
3940
* move.
4041
*/
4142

4243
import type { ObjectQL } from './engine.js';
44+
import type { ValidationMessageContext } from './validation/record-validator.js';
4345
import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system';
4446

4547
/**
@@ -52,22 +54,88 @@ import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system';
5254
*/
5355
export interface ValidationMessageHost {
5456
readonly i18nService: ObjectQL['i18nService'];
57+
readonly registry: ObjectQL['registry'];
5558
negotiatedMessageLocale: ObjectQL['negotiatedMessageLocale'];
5659
}
5760

58-
/** The body of `ObjectQL#validationMessageContext`; the method's docblock there is its contract. */
61+
/**
62+
* The body of `ObjectQL#validationMessageContext`; the method's docblock there is its contract.
63+
*
64+
* [ADR-0029 D9.2a] It also carries `packagedRule` (`ValidationMessageContext`),
65+
* the lookup the rule evaluator judges a tenant's rewrite of a rule message by,
66+
* when the object has a packaged owner — see {@link packagedRuleLookup}.
67+
*/
5968
export function validationMessageContext(
60-
this: Pick<ValidationMessageHost, 'i18nService' | 'negotiatedMessageLocale'>,
69+
this: Pick<ValidationMessageHost, 'i18nService' | 'registry' | 'negotiatedMessageLocale'>,
6170
objectName: string,
6271
// Only `locale` is read — narrower than `ExecutionContext` so both the
6372
// resolved and the input-shaped envelope satisfy it.
6473
context?: { locale?: string },
65-
): { locale?: string; translate?: (key: string, locale: string, params?: Record<string, unknown>) => string; objectName: string } {
74+
): ValidationMessageContext & { objectName: string } {
6675
const t = this.i18nService?.t;
76+
const packagedRule = packagedRuleLookup(this.registry, objectName);
6777
return {
6878
objectName,
6979
locale: this.negotiatedMessageLocale(context?.locale),
7080
translate: t ? (key, locale, params) => t.call(this.i18nService, key, locale, params) : undefined,
81+
...(packagedRule !== undefined ? { packagedRule } : {}),
82+
};
83+
}
84+
85+
/**
86+
* How deep {@link packagedRuleLookup} follows `conditional` branches — the
87+
* bound `/meta`'s `translateObjectValidations` walks to, so both readers judge
88+
* the same branches.
89+
*/
90+
const MAX_VALIDATION_DEPTH = 10;
91+
92+
/**
93+
* [ADR-0029 D9.2a] The packaged (code-shipped) rule of a name on `objectName`,
94+
* for the rule evaluator's divergence test.
95+
*
96+
* The declaration is the packaged OWNER's (`getPackagedObjectOwner`, pre-fold),
97+
* the same body `/meta` hands `translateObject` as `packagedBase` through
98+
* `getPackagedObjectBase`, so both readers compare a served message against one
99+
* packaged rule. Rules are keyed by `name`, each `conditional` branch under its
100+
* own name; the first rule of a name wins, as on `/meta`.
101+
*
102+
* `undefined` — nothing inferred, the catalog applies — when no packaged owner
103+
* is registered for the object, or the registry cannot answer (a partial
104+
* double). The index is built on the first lookup, which a write that refuses
105+
* nothing never makes.
106+
*/
107+
function packagedRuleLookup(
108+
registry: ValidationMessageHost['registry'] | undefined,
109+
objectName: string,
110+
): ((ruleName: string) => unknown) | undefined {
111+
if (typeof registry?.getPackagedObjectOwner !== 'function') return undefined;
112+
let definition: unknown;
113+
try {
114+
definition = registry.getPackagedObjectOwner(objectName)?.definition;
115+
} catch {
116+
// A read over in-memory contributors must not turn a 400 into a 500.
117+
return undefined;
118+
}
119+
if (!definition || typeof definition !== 'object') return undefined;
120+
const validations = (definition as { validations?: unknown }).validations;
121+
let byName: Map<string, unknown> | undefined;
122+
return (ruleName) => {
123+
if (byName === undefined) {
124+
const index = new Map<string, unknown>();
125+
const visit = (raw: unknown, depth: number): void => {
126+
if (!raw || typeof raw !== 'object' || depth >= MAX_VALIDATION_DEPTH) return;
127+
const rule = raw as { type?: unknown; name?: unknown; then?: unknown; otherwise?: unknown };
128+
if (rule.type === 'conditional') {
129+
visit(rule.then, depth + 1);
130+
visit(rule.otherwise, depth + 1);
131+
return;
132+
}
133+
if (typeof rule.name === 'string' && rule.name.length > 0 && !index.has(rule.name)) index.set(rule.name, rule);
134+
};
135+
if (Array.isArray(validations)) for (const raw of validations) visit(raw, 0);
136+
byName = index;
137+
}
138+
return byName.get(ruleName);
71139
};
72140
}
73141

0 commit comments

Comments
 (0)