Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/analytics-label-net-shared-glue-4389.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@object-ui/core': patch
'@object-ui/react': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-report': patch
---

Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface

PR #4388 (objectui#4330) put the same React glue on two surfaces — the dashboard's `DatasetWidget` and plugin-report's dataset block. The resolution RULES were never duplicated (both call the same `@object-ui/core` helpers), but the wiring around them was: read the object schema through the host's authenticated `apiFetch`, keep the fetched metadata locale-free in state, derive the label maps in a render memo. Two copies meant two statements of the same two bug fixes, which is a drift surface rather than a defect — nothing a user could hit today, filed as objectui#4389 so it was retired deliberately.

It is now split along the layer that can actually hold each half. `@object-ui/core` gains the React-free parts — `loadDimensionFieldMeta` (the base-object read composed with the dimension walk), `deriveDimensionLabelMaps` (the locale-applying derivation) and `dimensionOptionTranslator` (binding the bundle resolver to the object that OWNS a terminal field, which for a dotted path is the relationship target). `@object-ui/react` gains `useDatasetDimensionLabels` / `useDatasetDimensionMeta`, the React wiring that cannot live in core, beside the `useViewData` / `useElementDataSource` / `useDiscovery` hooks that already read `SchemaRendererContext` the same way. Both plugins consume it; the dashboard keeps its chart-only per-category colour and category-order derivation layered locally, since a table renders no palette.

The card originally proposed `@object-ui/core` as the whole glue's home. That home was disproven by measurement and retired in the card's PM RULING #2: `SchemaRendererContext` is defined in `@object-ui/react`, which depends on core, so core importing it back is a cycle — and core is React-free by declaration, by content, and by the topology in AGENTS.md. objectui#3367 had already ruled this direction for the same family (core-canonical logic, react re-exports).

Behaviour is unchanged by construction: same read count, same best-effort fallback, same memoization boundary. The two bug fixes are now stated once and pinned at the shared hook — the read rides the host's authenticated `apiFetch` (objectui#4121, pinned by asserting that a new channel re-issues the read, i.e. that it really is in the effect's deps), and the fetched metadata stays locale-free (objectui#4030 / PR #4324, pinned by switching language at runtime and asserting the labels flip with no second metadata read). All 39 assertions PR #4388 landed across both surfaces pass unchanged, and their files are byte-identical to before.
227 changes: 227 additions & 0 deletions packages/core/src/utils/__tests__/chart-series.labelNetGlue.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The analytics label net's REACT-FREE half (objectui#4389) — `loadDimensionFieldMeta`,
* `deriveDimensionLabelMaps` and `dimensionOptionTranslator`.
*
* These three were written out longhand inside `DatasetWidget`'s effect/memo and
* again inside plugin-report's `useDatasetDimensionLabels` (PR #4388). They are
* pure, so they belong on this side of the layer — the React wiring that could
* not follow them here (the `SchemaRendererContext` read, the state, the memo
* boundary) lives in `@object-ui/react`, for the dependency-direction reason
* recorded in the card's PM RULING #2.
*
* The pins below assert the two facts the longhand copies each had to get right
* on their own, and which are the reason this is worth sharing at all:
*
* - the base object is read ONCE per query, and a dotted path's hop is read
* once too — the memoized loader, not one read per dimension;
* - the translator is bound to the object that OWNS the terminal field (the
* relationship TARGET for a dotted path), because that owner is the key the
* locale bundle is written under.
*/

import { describe, it, expect, vi } from 'vitest';
import {
deriveDimensionLabelMaps,
dimensionOptionTranslator,
loadDimensionFieldMeta,
type DimensionFieldMeta,
} from '../chart-series';

const CHANNEL_OPTIONS = [
{ value: 'domestic', label: 'Domestic' },
{ value: 'export', label: 'Export' },
];
const INDUSTRY_OPTIONS = [
{ value: 'chem', label: 'Chemicals' },
{ value: 'auto', label: 'Automotive' },
];

const OPPORTUNITY = {
name: 'crm_opportunity',
fields: {
sales_channel: { type: 'select', options: CHANNEL_OPTIONS },
amount: { type: 'currency' },
account: { type: 'lookup', reference: 'crm_account' },
},
};
const ACCOUNT = {
name: 'crm_account',
fields: { industry: { type: 'select', options: INDUSTRY_OPTIONS } },
};

function makeLoader(docs: Record<string, unknown>) {
const requested: string[] = [];
const load = vi.fn(async (name: string) => {
requested.push(name);
return docs[name] ?? null;
});
return { load, requested };
}

describe('loadDimensionFieldMeta (objectui#4389)', () => {
it('reads the base object once and resolves every local dimension from it', async () => {
const { load, requested } = makeLoader({ crm_opportunity: OPPORTUNITY });

const meta = await loadDimensionFieldMeta(load, 'crm_opportunity', ['sales_channel', 'amount']);

// ONE read for the whole query — the base schema is seeded into the walk's
// cache under its own `name`, so resolving N dimensions never re-reads it.
expect(requested).toEqual(['crm_opportunity']);
expect(meta['sales_channel']).toEqual({
object: 'crm_opportunity',
field: 'sales_channel',
options: CHANNEL_OPTIONS,
});
// A field carrying no `options` yields NO entry — this is the exact gate
// that makes the read safe to issue unconditionally (objectui#4330).
expect(meta['amount']).toBeUndefined();
});

it('walks a dotted path to the relationship target and keeps that target as the owner', async () => {
const { load, requested } = makeLoader({ crm_opportunity: OPPORTUNITY, crm_account: ACCOUNT });

const meta = await loadDimensionFieldMeta(load, 'crm_opportunity', [
'sales_channel',
'account.industry',
]);

expect(requested).toEqual(['crm_opportunity', 'crm_account']);
expect(meta['account.industry']).toEqual({
object: 'crm_account',
field: 'industry',
options: INDUSTRY_OPTIONS,
});
});

it('reads a shared relationship prefix once across sibling dimensions', async () => {
const ACCOUNT_TWO_SELECTS = {
name: 'crm_account',
fields: {
industry: { type: 'select', options: INDUSTRY_OPTIONS },
tier: { type: 'select', options: [{ value: 'a', label: 'A' }] },
},
};
const { load, requested } = makeLoader({
crm_opportunity: OPPORTUNITY,
crm_account: ACCOUNT_TWO_SELECTS,
});

await loadDimensionFieldMeta(load, 'crm_opportunity', ['account.industry', 'account.tier']);

expect(requested).toEqual(['crm_opportunity', 'crm_account']);
});
});

describe('dimensionOptionTranslator (objectui#4389)', () => {
const meta: DimensionFieldMeta = {
object: 'crm_account',
field: 'industry',
options: INDUSTRY_OPTIONS,
};

it('binds the resolver to the OWNING object and terminal field', () => {
const seen: Array<[string, string, string, string]> = [];
const translate = dimensionOptionTranslator(meta, (o, f, v, a) => {
seen.push([o, f, v, a]);
return `${o}.${f}.${v}`;
});

expect(translate?.('chem', 'Chemicals')).toBe('crm_account.industry.chem');
// The owner is the relationship TARGET, never the dataset's base object.
expect(seen).toEqual([['crm_account', 'industry', 'chem', 'Chemicals']]);
});

it('returns undefined when the owner is unresolved or no resolver was given', () => {
expect(dimensionOptionTranslator(meta, undefined)).toBeUndefined();
expect(dimensionOptionTranslator(undefined, (_o, _f, _v, a) => a)).toBeUndefined();
expect(
dimensionOptionTranslator({ object: undefined, field: 'industry', options: [] }, (_o, _f, _v, a) => a),
).toBeUndefined();
});
});

describe('deriveDimensionLabelMaps (objectui#4389)', () => {
const metaByPath: Record<string, DimensionFieldMeta> = {
sales_channel: { object: 'crm_opportunity', field: 'sales_channel', options: CHANNEL_OPTIONS },
'account.industry': { object: 'crm_account', field: 'industry', options: INDUSTRY_OPTIONS },
};
const relabel = [
{ dim: 'sales_channel', path: 'sales_channel' },
{ dim: 'industry', path: 'account.industry' },
];

/** A zh bundle, as `fieldOptionLabel` would resolve it. */
const zh = (object: string, field: string, value: string, authored: string) =>
({
'crm_opportunity.sales_channel.domestic': '国内',
'crm_opportunity.sales_channel.export': '出口',
'crm_account.industry.chem': '化工',
} as Record<string, string>)[`${object}.${field}.${value}`] ?? authored;

it('keys each dimension by BOTH the stored value and the authored label', () => {
const maps = deriveDimensionLabelMaps(metaByPath, relabel, zh);

expect(maps?.sales_channel).toEqual({
domestic: '国内',
Domestic: '国内',
export: '出口',
Export: '出口',
});
// `auto` has no bundle entry, so its display falls back to the AUTHORED
// label — which still differs from the stored value, so it keeps a
// value → label key. That is the net's ORIGINAL job (objectui#4053:
// resolve a raw stored enum the server did not resolve) surviving
// underneath the #4030 translation layer, not a leak. It gains no
// authored-label key, because for it display === label.
expect(maps?.industry).toEqual({ chem: '化工', Chemicals: '化工', auto: 'Automotive' });
});

it('is the pre-#4030 value → label map when no translator is supplied', () => {
// MEASURED, and the opposite of what this pin first asserted: without a
// translator the display IS the authored label, so no AUTHORED-label key is
// ever emitted — but the value → label keys remain, because that resolution
// predates #4030 and is the reason the net exists at all (objectui#4053).
// Reporting the behaviour rather than the guess: a translator-free call is
// not a no-op, it is the original resolver.
expect(deriveDimensionLabelMaps(metaByPath, relabel)).toEqual({
sales_channel: { domestic: 'Domestic', export: 'Export' },
industry: { chem: 'Chemicals', auto: 'Automotive' },
});
});

it('returns null when relabeling really would be a no-op (bare-string options)', () => {
// Bare-string options are their own label, so value === label: nothing to
// resolve and nothing to translate. THIS is the null case, and it is what
// lets a caller skip `relabelDimensions` and keep its row array identity.
const bare: Record<string, DimensionFieldMeta> = {
sales_channel: {
object: 'crm_opportunity',
field: 'sales_channel',
options: ['domestic', 'export'],
},
};
expect(
deriveDimensionLabelMaps(bare, [{ dim: 'sales_channel', path: 'sales_channel' }]),
).toBeNull();
});

it('returns null for empty or absent inputs rather than an empty object', () => {
expect(deriveDimensionLabelMaps(null, relabel, zh)).toBeNull();
expect(deriveDimensionLabelMaps(metaByPath, null, zh)).toBeNull();
expect(deriveDimensionLabelMaps(metaByPath, [], zh)).toBeNull();
expect(deriveDimensionLabelMaps({}, relabel, zh)).toBeNull();
});

it('skips a dimension whose path resolved nothing, keeping the others', () => {
const maps = deriveDimensionLabelMaps(
metaByPath,
[...relabel, { dim: 'unresolved', path: 'no.such.path' }],
zh,
);

expect(maps).not.toBeNull();
expect(Object.keys(maps ?? {})).toEqual(['sales_channel', 'industry']);
});
});
132 changes: 132 additions & 0 deletions packages/core/src/utils/chart-series.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -548,3 +548,135 @@ export async function resolveDimensionFieldMeta(
}
return out;
}

/* ────────────────────────────────────────────────────────────────────────────
* The analytics label net's REACT-FREE half (objectui#4389)
*
* Everything below composes the helpers above into the two steps every dataset
* surface takes: LOAD the metadata for a query's dimensions, then DERIVE the
* `{dimension → {value|authoredLabel → displayLabel}}` maps from it. Both were
* written out longhand twice — once in `plugin-dashboard`'s `DatasetWidget` and
* once in `plugin-report`'s `useDatasetDimensionLabels` (objectui#4330 / PR
* #4388) — and the duplication was filed as objectui#4389.
*
* The card's first ruling named `@object-ui/core` as the whole glue's home;
* that half of it was retired by measurement (PM RULING #2 on the card):
* `SchemaRendererContext` is defined in `@object-ui/react`, which DEPENDS on
* this package, so core importing it back is a cycle — and core is React-free
* by declaration, by content, and by AGENTS.md §3. The layering was already
* ruled in this exact direction by objectui#3367 (core-canonical logic, react
* re-exports; never core importing react).
*
* So the split is: the parts that are just data — a load composition and a
* pure derivation — live HERE, beside the helpers they call. The React wiring
* that cannot (useState / useEffect / useMemo / the context read) lives once in
* `@object-ui/react`'s `useDatasetDimensionLabels`, which both plugins consume.
* Neither half knows about the other's layer, and neither is written twice.
* ──────────────────────────────────────────────────────────────────────────── */

/**
* The locale-bundle resolver a caller supplies to translate one option label —
* `useSafeFieldLabel().fieldOptionLabel` in every current caller, i.e. the
* `{ns}.fieldOptions.<object>.<field>.<value>` convention list and form
* surfaces already translate select options through (objectui#4030).
*
* Taken as a plain function so this package needs no knowledge of i18n, React,
* or which provider is mounted — the same reason {@link OptionLabelTranslator}
* is a bare function type.
*/
export type FieldOptionLabelResolver = (
object: string,
field: string,
value: string,
authoredLabel: string,
) => string;

/**
* Bind a {@link FieldOptionLabelResolver} to ONE resolved dimension field,
* yielding the {@link OptionLabelTranslator} the option helpers take.
*
* The binding is the part that is easy to get wrong, which is why it is stated
* once here rather than at each call site: the translator must be bound to the
* object that OWNS the terminal field — `crm_account` for `crm_account.industry`,
* NOT the dataset's base object — because that owner is the key the locale
* bundle is written under. {@link resolveDimensionFieldMeta} already walked the
* relationship chain and kept that owner; this just reads it.
*
* Returns `undefined` — i.e. "no translation, keep the authored labels" — when
* the owner could not be resolved or no resolver was supplied. Every helper
* downstream then behaves exactly as it did before the #4030 seam existed.
*/
export function dimensionOptionTranslator(
meta: DimensionFieldMeta | undefined,
fieldOptionLabel?: FieldOptionLabelResolver,
): OptionLabelTranslator | undefined {
const owner = meta?.object;
if (!owner || !fieldOptionLabel) return undefined;
const field = meta.field;
return (value, authoredLabel) => fieldOptionLabel(owner, field, value, authoredLabel);
}

/** One dimension paired with the field path its values resolve through. */
export interface DimensionRelabelTarget {
/** The dimension name as the surface renders it (the row key). */
dim: string;
/** That dimension's underlying field path, as the dataset query reported it. */
path: string;
}

/**
* Derive the `{ dimension → { value|authoredLabel → displayLabel } }` maps that
* {@link relabelDimensions} consumes, from metadata {@link loadDimensionFieldMeta}
* (or {@link resolveDimensionFieldMeta}) already resolved.
*
* This is the LOCALE-APPLYING step, and keeping it separate from the load is
* the whole point of the split (objectui#4030 / PR #4324): the fetched metadata
* stays locale-free, so switching language re-derives these maps in a render
* memo instead of re-issuing the metadata read. A caller that folds the two
* together reintroduces a refetch on every language switch — the defect #4324
* fixed, and the property objectui#4389 lifted here so it is stated once.
*
* Best-effort per dimension, exactly as the longhand copies were: a path that
* resolved no metadata, or whose field carries no `options`, contributes no
* entry, and `relabelDimensions` then returns the caller's rows by identity.
* Returns `null` — never an empty object — when nothing resolved, so callers
* can skip the relabel entirely.
*/
export function deriveDimensionLabelMaps(
metaByPath: Record<string, DimensionFieldMeta> | null | undefined,
relabel: readonly DimensionRelabelTarget[] | null | undefined,
fieldOptionLabel?: FieldOptionLabelResolver,
): Record<string, Record<string, string>> | null {
if (!metaByPath || !relabel || relabel.length === 0) return null;
const labels: Record<string, Record<string, string>> = {};
for (const { dim, path } of relabel) {
const meta = metaByPath[path];
const map = buildDimensionLabelMap(meta?.options, dimensionOptionTranslator(meta, fieldOptionLabel));
if (map) labels[dim] = map;
}
return Object.keys(labels).length > 0 ? labels : null;
}

/**
* Load one dataset query's dimension field metadata: fetch the base object's
* schema, then resolve every dimension's field path against it.
*
* The two-step composition every caller wrote out by hand. `loadObjectSchema`
* stays the CALLER's channel — this package issues no reads of its own and has
* no opinion about transport, which is what keeps it free of React, of
* `SchemaRendererContext`, and of the authenticated-`apiFetch` concern
* (objectui#4121) that belongs to the layer holding the host context.
*
* The same loader serves both steps by design: {@link resolveDimensionFieldMeta}
* memoizes it per call and seeds the cache with the base schema under its own
* `name`, so the base object is read ONCE even when several dotted dimensions
* walk back through it. That read count is pinned on both consuming surfaces.
*/
export async function loadDimensionFieldMeta(
loadObjectSchema: (objectName: string) => Promise<unknown>,
object: string,
fieldPaths: Array<string | undefined | null>,
): Promise<Record<string, DimensionFieldMeta>> {
const baseSchema = await loadObjectSchema(object);
return resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObjectSchema);
}
Loading
Loading