diff --git a/.changeset/analytics-label-net-shared-glue-4389.md b/.changeset/analytics-label-net-shared-glue-4389.md new file mode 100644 index 000000000..9e6bf8a0d --- /dev/null +++ b/.changeset/analytics-label-net-shared-glue-4389.md @@ -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. diff --git a/packages/core/src/utils/__tests__/chart-series.labelNetGlue.test.ts b/packages/core/src/utils/__tests__/chart-series.labelNetGlue.test.ts new file mode 100644 index 000000000..5e20b789d --- /dev/null +++ b/packages/core/src/utils/__tests__/chart-series.labelNetGlue.test.ts @@ -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) { + 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 = { + 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)[`${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 = { + 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']); + }); +}); diff --git a/packages/core/src/utils/chart-series.ts b/packages/core/src/utils/chart-series.ts index e8f6f6dc7..1965e7313 100644 --- a/packages/core/src/utils/chart-series.ts +++ b/packages/core/src/utils/chart-series.ts @@ -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...` 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 | null | undefined, + relabel: readonly DimensionRelabelTarget[] | null | undefined, + fieldOptionLabel?: FieldOptionLabelResolver, +): Record> | null { + if (!metaByPath || !relabel || relabel.length === 0) return null; + const labels: Record> = {}; + 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, + object: string, + fieldPaths: Array, +): Promise> { + const baseSchema = await loadObjectSchema(object); + return resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObjectSchema); +} diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index 94ead0bdf..db06506b3 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -26,16 +26,22 @@ * objectui bumps its `@objectstack/spec` dependency (cross-repo spec skew). */ -import { useContext, useEffect, useMemo, useState } from 'react'; -import { SchemaRenderer, SchemaRendererContext } from '@object-ui/react'; +import { useEffect, useMemo, useState } from 'react'; +// `useDatasetDimensionMeta` is the analytics label net's SHARED React glue +// (objectui#4389): the locale-free metadata read this widget and the dataset +// report block both take, lifted out of the two longhand copies PR #4388 left +// behind. It lives in `@object-ui/react` because it reads the host context — +// see its file header for the measured dependency direction. This widget +// layers its CHART-ONLY colour/order derivation on top, below. +import { SchemaRenderer, useDatasetDimensionMeta } from '@object-ui/react'; import { buildChartSeries, buildOptionColorMap, - buildDimensionLabelMap, buildCategoryOrder, relabelDimensions, localizeFieldOptions, - resolveDimensionFieldMeta, + deriveDimensionLabelMaps, + dimensionOptionTranslator, findChartSeriesRow, formatMeasure, formatDimensionValue, @@ -53,8 +59,6 @@ import { pivotCellKey, compareToTrendLabelKey, type ChartSeriesBinding, - type DimensionFieldMeta, - type OptionLabelTranslator, type CompareToConfig, type DatasetResultField, type DatasetDrillRange, @@ -706,15 +710,12 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // a user-scoped widget sent `{current_user_id}` to SQL as a literal, matched // no row, and rendered 0 with no error anywhere (framework #3574). const filterScope = useFilterScope(); - // Host-authenticated fetch for the object-schema probe further down - // (objectui#4121). This widget takes its `dataSource` as a PROP and reads no - // other context, so the channel has to be read here — the same context the - // rest of the family reads (`ObjectChart`, `ObjectGantt`, `useViewData`, - // `useRecordEditable`), consulted DIRECTLY rather than via - // `useSchemaContext()`, which throws when no provider is mounted: a widget - // rendered outside a host (every existing suite in this package) must keep - // degrading to the global fetch instead of crashing the render. - const apiFetch = useContext(SchemaRendererContext)?.apiFetch; + // The object-schema probe's host-authenticated fetch (objectui#4121) is no + // longer read here: it moved INTO `useDatasetDimensionMeta` along with the + // rest of the read, so the property is stated once for both surfaces instead + // of once per surface (objectui#4389). The hook consults the context the same + // way this widget did — directly, not via a throwing `useSchemaContext()` — + // so a widget rendered outside a host still degrades to the global fetch. const rawFilter = widget?.filter; const runtimeFilter = useMemo( () => (rawFilter && typeof rawFilter === 'object' && Object.keys(rawFilter).length > 0 @@ -741,24 +742,6 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Array>; drillRanges?: Array>; totals?: DatasetTotals[]; error?: string }>({ status: 'idle', rows: [] }); // Drill-through (ADR-0021 D2): the clicked bucket's record-list filter + title. const [drill, setDrill] = useState<{ filter: Record; title: string } | null>(null); - // ── The analytics label net's INPUT: resolved field metadata, locale-free ── - // What the object-schema probe below found for this widget's dimensions — - // the raw `{ object, field, options }` per dimension field path, exactly as - // the metadata doc carries it. Everything the surface actually displays - // (per-category colours, {value → label} maps, the declared category order) - // is DERIVED from it during render, one memo down, because that derivation - // is where the locale bundle applies (objectui#4030): keeping the fetched - // metadata locale-free means switching language re-renders the labels - // instead of re-fetching the schema, and the i18n application sits at the - // net's output rather than inside its fetch. - const [optionMeta, setOptionMeta] = useState<{ - metaByPath: Record; - /** The dimensions this widget relabels, paired with their field paths. */ - relabel: Array<{ dim: string; path: string }>; - /** First dimension's path — chart wiring only; undefined on table/pivot. */ - firstDimPath?: string; - } | null>(null); - // Signature uses the RAW filter (stable) — the resolved one carries a // render-time `now` and would otherwise force a refetch loop. The // query-affecting options join it so editing a widget's granularity/sort in @@ -790,116 +773,65 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // eslint-disable-next-line react-hooks/exhaustive-deps }, [signature]); - // Resolve the dimensions' select/lookup field options. The dataset query - // gives us the base `object` + the dimension→field map, so ONE object schema - // fetch yields both: a {value|label → color} map for the first dimension's - // per-category colors (charts only — a table renders no palette), and a - // {value → label} map per dimension so the axis/series/cells display labels - // even when the server returned raw values, and so the locale bundle has an - // option list to translate against (objectui#4030 / #4330). - // Best-effort: any failure leaves both null (positional palette + raw values). + // ── The analytics label net's INPUT: resolved field metadata, locale-free ── + // ONE object-schema read per dataset query, yielding `{ object, field, + // options }` per dimension field path. Everything this widget DISPLAYS + // (per-category colours, {value → label} maps, the declared category order) + // is derived from it during render, one memo down, because that derivation is + // where the locale bundle applies (objectui#4030): keeping the fetched + // metadata locale-free means switching language re-renders the labels instead + // of re-fetching the schema. // - // The read rides the host's AUTHENTICATED fetch (objectui#4121) — the same - // channel `provider: 'api'` view sources use — falling back to the global one - // only when no host supplies it. A bearer-token session carries its credential - // in the `Authorization` header, not a cookie, so `credentials: 'include'` - // alone left this read unauthenticated in a hosted console; combined with the - // best-effort shape above the symptom was not an error but semantic option - // colors and dimension labels silently never applying. - useEffect(() => { - const object = state.object; - // The METRIC branch renders ONE measure value plus that measure's header - // label — a dimension's value never reaches its output in any spelling — so - // there is nothing on that path to relabel and resolving would be a - // metadata read nothing consumes (objectui#4263, pinned). - if (isMetric || !object || dimensions.length === 0) { setOptionMeta(null); return; } - const fieldOf = (dim: string) => (state.dimensionFields && state.dimensionFields[dim]) || dim; - // ── Which dimensions this widget type resolves (objectui#4263 → #4330) ── - // EVERY dimension, on every non-metric widget type — one rule, no - // per-widget-type dialect. - // - // #4263 ruled a narrower one: on table/pivot the SERVER resolves a LOCAL - // dimension's display label (ADR-0021), so this client net stayed off - // there and opened only for DOTTED paths, where the server is silent too. - // That boundary was ruled for LABEL RESOLUTION — "the label already - // exists, don't produce it twice" — and it held exactly as long as - // resolution was the only thing downstream of the read. objectui#4030 / - // PR #4324 put a second consumer there: the locale bundle - // (`localizeFieldOptions` / `buildDimensionLabelMap`'s translator), which - // needs the option LIST, not the label. So a local select on a table had - // its label resolved by the server, in English, with nothing on the client - // to translate it against — the cells read `Domestic` beside a related - // list reading 国内 (objectui#4330). The read is what closes that, and the - // PM amended the #4263 boundary deliberately for it. - // - // It is not a second resolution: `buildDimensionLabelMap` carries BOTH the - // stored value and the AUTHORED label as keys, and `relabelDimensions` is - // value-wise and idempotent — a server-resolved `Domestic` maps to 国内 - // once, and under `en` (or with no bundle entry) the display equals the - // authored label, so no key is emitted and the rows pass through by - // identity. - // - // WHY THE READ IS NOT GATED ON "the dimension is a select" (measured, see - // the amended pins): select-ness is not observable before the read. - // `DatasetDimension.type` is `string|number|date|boolean|lookup` — the spec - // has no `select` member — and every select dimension in the live example - // apps declares `type: 'string'`; on the wire, `AnalyticsResult.fields[]` - // types a select column `'string'` too (the analytics cube registry's - // `fieldTypeToDimensionType` default). The select gate therefore lands on - // the read's OUTPUT, where it is exact: `resolveDimensionFieldMeta` yields - // an entry only for a terminal field that actually carries `options`, so a - // text / number / date / lookup dimension produces no map and no relabel. - const resolveDims = dimensions; - if (resolveDims.length === 0) { setOptionMeta(null); return; } - let cancelled = false; - (async () => { - try { - const doFetch = apiFetch ?? fetch; - const loadObjectSchema = async (name: string) => { - const r = await doFetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, { headers: { accept: 'application/json' }, credentials: 'include' }); - const doc = await r.json().catch(() => null); - return doc?.item ?? doc?.data ?? doc; - }; - const objSchema = await loadObjectSchema(object); - // A dimension's field may be a DOTTED relationship path - // (`crm_account.industry`) whose options live on the RELATED object, not - // on the dataset's base object — objectui#4053. `resolveDimensionFieldMeta` - // is the object-resolution step of this same lookup: a local field name - // still resolves straight off `objSchema`, a dotted one walks each hop - // through the SAME channel `objSchema` came from. Unresolvable paths - // yield no entry, so the raw value survives exactly as before. It keeps - // the OWNING object and terminal field name beside the options, which - // is the key the locale bundle is written under (objectui#4030). - const metaByPath = await resolveDimensionFieldMeta( - objSchema, - resolveDims.map(fieldOf), - loadObjectSchema, - ); - if (!cancelled) { - setOptionMeta({ - metaByPath, - relabel: resolveDims.map((dim) => ({ dim, path: fieldOf(dim) })), - // Per-category COLOURS and the declared category ORDER are chart - // wiring — they key the palette and the axis sequence, neither of - // which a table or pivot renders. They stay null on that path - // exactly as they did when it returned early (objectui#4263), and - // #4330 widened only WHICH DIMENSIONS get a label map, never what - // a table consumes. - firstDimPath: isTable ? undefined : fieldOf(dimensions[0]), - }); - } - } catch { if (!cancelled) setOptionMeta(null); } - })(); - return () => { cancelled = true; }; - // `apiFetch` joins the deps (objectui#4121) exactly as it does in - // `useRecordEditable`'s. It does not re-open this file's documented refetch - // concern — that one is on the query effect above and is about a - // render-time `now` inside the RESOLVED filter, i.e. a value this component - // recomputes every render. `apiFetch` is not: it comes from the provider's - // memoized context value, and this widget's own `setState` cannot re-render - // the provider, so the identity is stable across the effect's own updates. - // The in-repo host holds it at module scope (`ConsoleShell.tsx:187` → `:245`). - }, [state.object, state.dimensionFields, dimensions, isMetric, isTable, apiFetch]); + // The read itself — the host-authenticated `apiFetch` channel (objectui#4121), + // the locale-free state, the effect deps, the best-effort failure shape — now + // lives ONCE in `@object-ui/react`'s `useDatasetDimensionMeta`, shared with + // the dataset report block (objectui#4389). It was written out here and again + // in plugin-report by PR #4388; both copies stated the same two bug fixes, and + // that is precisely the drift #4389 was filed to close. What stays local is + // the part that is genuinely this widget's: WHICH dimensions to resolve, and + // the chart-only colour/order derivation in the memo below. + // + // The METRIC branch renders ONE measure value plus that measure's header + // label — a dimension's value never reaches its output in any spelling — so + // there is nothing on that path to relabel and resolving would be a metadata + // read nothing consumes (objectui#4263, pinned via `enabled`). + // + // Which dimensions resolve (objectui#4263 → #4330): EVERY dimension, on every + // non-metric widget type — one rule, no per-widget-type dialect. #4263 ruled a + // narrower one: on table/pivot the SERVER resolves a LOCAL dimension's display + // label (ADR-0021), so this client net stayed off there and opened only for + // DOTTED paths, where the server is silent too. That boundary was ruled for + // LABEL RESOLUTION — "the label already exists, don't produce it twice" — and + // it held exactly as long as resolution was the only thing downstream of the + // read. objectui#4030 / PR #4324 put a second consumer there: the locale + // bundle, which needs the option LIST, not the label. So a local select on a + // table had its label resolved by the server, in English, with nothing on the + // client to translate it against — the cells read `Domestic` beside a related + // list reading 国内 (objectui#4330). The read is what closes that, and the PM + // amended the #4263 boundary deliberately for it. + // + // It is not a second resolution: `buildDimensionLabelMap` carries BOTH the + // stored value and the AUTHORED label as keys, and `relabelDimensions` is + // value-wise and idempotent — a server-resolved `Domestic` maps to 国内 once, + // and under `en` (or with no bundle entry) the display equals the authored + // label, so no key is emitted and the rows pass through by identity. + // + // WHY THE READ IS NOT GATED ON "the dimension is a select" (measured, see the + // amended pins): select-ness is not observable before the read. + // `DatasetDimension.type` is `string|number|date|boolean|lookup` — the spec + // has no `select` member — and every select dimension in the live example apps + // declares `type: 'string'`; on the wire, `AnalyticsResult.fields[]` types a + // select column `'string'` too (the analytics cube registry's + // `fieldTypeToDimensionType` default). The select gate therefore lands on the + // read's OUTPUT, where it is exact: `resolveDimensionFieldMeta` yields an entry + // only for a terminal field that actually carries `options`, so a text / + // number / date / lookup dimension produces no map and no relabel. + const dimensionMeta = useDatasetDimensionMeta( + state.object, + state.dimensionFields, + dimensions, + { enabled: !isMetric }, + ); // ── The analytics label net's OUTPUT, with the locale bundle applied ────── // objectui#4030 (source thread objectstack#5076): the net above RESOLVES a @@ -912,37 +844,35 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // use (`fieldOptions...`), reached through the // provider-safe wrapper so a widget rendered without an I18nProvider keeps // its authored labels instead of crashing. + // + // `deriveDimensionLabelMaps` is core's — the same derivation the dataset + // report block runs, stated once (objectui#4389). Per-category COLOURS and + // the declared category ORDER are NOT: they key the palette and the axis + // sequence, neither of which a table or pivot renders, so they stay null on + // that path exactly as they did when it returned early (objectui#4263). + // #4330 widened only WHICH DIMENSIONS get a label map, never what a table + // consumes — hence the `isTable` gate on `firstDimPath` and nothing else. const { categoryColors, dimensionLabels, categoryOrder } = useMemo(() => { - if (!optionMeta) return { categoryColors: null, dimensionLabels: null, categoryOrder: null }; - const { metaByPath, relabel, firstDimPath } = optionMeta; - // One translator per resolved path, bound to the object that OWNS the - // terminal field — `crm_account` for `crm_account.industry`, not the - // dataset's base object. A path whose owner could not be resolved gets no - // translator and keeps its authored labels. - const translatorFor = (path: string | undefined): OptionLabelTranslator | undefined => { - const meta = path ? metaByPath[path] : undefined; - const owner = meta?.object; - if (!owner) return undefined; - return (value, authored) => fieldOptionLabel(owner, meta.field, value, authored); - }; + if (!dimensionMeta) return { categoryColors: null, dimensionLabels: null, categoryOrder: null }; + const { metaByPath, relabel } = dimensionMeta; // Colours and declared order read `option.label`, so they are fed the // LOCALIZED options — the same "translate the options, then render them" // shape the list side uses (`translateOptions` → `SelectCellRenderer`). // That keeps them keyed by the string the relabeled rows actually carry. + // `relabel[0]` is the first dimension: `relabel` is built from the same + // already-`Boolean`-filtered `dimensions` array this widget passes in, in + // order, so `relabel[0].path` is `fieldOf(dimensions[0])`. + const firstDimPath = isTable ? undefined : relabel[0]?.path; + const firstDimMeta = firstDimPath ? metaByPath[firstDimPath] : undefined; const firstDimOptions = firstDimPath - ? localizeFieldOptions(metaByPath[firstDimPath]?.options, translatorFor(firstDimPath)) + ? localizeFieldOptions(firstDimMeta?.options, dimensionOptionTranslator(firstDimMeta, fieldOptionLabel)) : undefined; - const labels: Record> = {}; - for (const { dim, path } of relabel) { - const m = buildDimensionLabelMap(metaByPath[path]?.options, translatorFor(path)); - if (m) labels[dim] = m; - } return { categoryColors: buildOptionColorMap(firstDimOptions), - dimensionLabels: Object.keys(labels).length > 0 ? labels : null, + dimensionLabels: deriveDimensionLabelMaps(metaByPath, relabel, fieldOptionLabel), categoryOrder: buildCategoryOrder(firstDimOptions), }; - }, [optionMeta, fieldOptionLabel]); + }, [dimensionMeta, fieldOptionLabel, isTable]); if (values.length === 0) { return
{tt('dashboard.pickMeasures', 'Pick measures (values) for this dataset widget.')}
; diff --git a/packages/plugin-report/src/useDatasetDimensionLabels.ts b/packages/plugin-report/src/useDatasetDimensionLabels.ts index 72086844d..3bd339b70 100644 --- a/packages/plugin-report/src/useDatasetDimensionLabels.ts +++ b/packages/plugin-report/src/useDatasetDimensionLabels.ts @@ -2,7 +2,7 @@ /** * useDatasetDimensionLabels — the analytics label net for dataset-bound - * REPORTS (objectui#4330). + * REPORTS (objectui#4330), now a RE-EXPORT of the shared hook (objectui#4389). * * ## What this is * @@ -20,14 +20,25 @@ * form surfaces already translate select options through. Nothing here decides * what a label IS; it only carries the object metadata to the helpers that do. * - * ## Why a report-local hook rather than a shared one + * ## Why this file is now three lines (objectui#4389) * - * The natural home for this glue is `@object-ui/core`, beside the helpers it - * calls. It is here instead because `packages/core` is held by another task in - * flight (objectui#4040 tranche 5) and this card's surface is the two plugin - * packages. The DUPLICATION is the fetch-and-memo wiring only — roughly the - * shape `DatasetWidget`'s effect has — and never the resolution rules. Lifting - * it into core once tranche 5 lands is filed as objectui#4389. + * It used to hold a report-local COPY of the React glue — the context read, the + * locale-free state, the effect, the memo — because `packages/core` was held by + * objectui#4040 tranche 5 while #4330 was in flight and that card's surface was + * the two plugin packages. `DatasetWidget` carried the same shape. The + * duplication was filed as objectui#4389 and is retired here. + * + * The glue did NOT land in `@object-ui/core` as the card originally proposed: + * 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 AGENTS.md §3. What could legally move into + * core did (`loadDimensionFieldMeta`, `deriveDimensionLabelMaps`, + * `dimensionOptionTranslator` — all pure); the React wiring lives once in + * `@object-ui/react`, which both plugins already depend on. + * + * This file stays as the import path so `DatasetReportRenderer`'s three call + * sites are untouched, and so this rationale keeps a home next to its consumer. * * ## Why the read is issued at all (the #4263 boundary, amended by #4330) * @@ -46,114 +57,18 @@ * only for a terminal field that actually carries `options`, so a * text/number/date/lookup dimension yields no map and `relabelDimensions` * returns the caller's rows by identity. - */ - -import * as React from 'react'; -import { - buildDimensionLabelMap, - resolveDimensionFieldMeta, - type DimensionFieldMeta, - type OptionLabelTranslator, -} from '@object-ui/core'; -import { SchemaRendererContext } from '@object-ui/react'; -import { useSafeFieldLabel } from '@object-ui/i18n'; - -/** `{ dimension → { rowValue → displayLabel } }`, or null when nothing resolved. */ -export type DimensionLabelMaps = Record> | null; - -/** - * Resolve the label maps for one dataset query's dimensions. * - * @param object the dataset's base object, as the query result reported it - * @param dimensionFields the result's `dimension → field path` map (a dotted - * path resolves against the relationship TARGET, ADR-0071 multi-hop included) - * @param dimensions the dimension names this surface renders + * ## The two properties this surface INHERITS rather than restates + * + * Both are bug fixes, and both used to be written out here as well as in the + * dashboard — which is the drift objectui#4389 closed: + * + * - the read rides the host's AUTHENTICATED `apiFetch` (objectui#4121); + * - the fetched metadata stays LOCALE-FREE in state (objectui#4030 / PR #4324), + * so a language switch re-labels in place instead of re-fetching. + * + * They are pinned once, at the shared hook, in + * `packages/react/src/hooks/__tests__/useDatasetDimensionLabels.test.tsx`. */ -export function useDatasetDimensionLabels( - object: string | undefined, - dimensionFields: Record | undefined, - dimensions: string[], -): DimensionLabelMaps { - const { fieldOptionLabel } = useSafeFieldLabel(); - // The host's AUTHENTICATED fetch (objectui#4121) — the same channel the - // dashboard widget's identical read rides, falling back to the global one - // when no host supplies it. Read directly off the context rather than through - // a `useSchemaContext()` that throws, so a report rendered outside a host - // (every existing suite in this package) keeps degrading instead of crashing. - const apiFetch = React.useContext(SchemaRendererContext)?.apiFetch; - - // Kept LOCALE-FREE in state, exactly as `DatasetWidget` keeps it (#4030): - // the bundle is applied in the memo below, so switching language re-labels in - // place instead of re-fetching the schema. - const [optionMeta, setOptionMeta] = React.useState<{ - metaByPath: Record; - relabel: Array<{ dim: string; path: string }>; - } | null>(null); - - // A string signature, for the same reason `useDatasetRows` uses one: `rows` / - // `columns` reach this renderer as arrays rebuilt on every render, so keying - // the effect on their identity would refetch forever. - const dims = dimensions.filter(Boolean); - const signature = `${object ?? ''}|${dims.join(',')}|${JSON.stringify(dimensionFields ?? null)}`; - - React.useEffect(() => { - if (!object || dims.length === 0) { - setOptionMeta(null); - return; - } - const fieldOf = (dim: string) => (dimensionFields && dimensionFields[dim]) || dim; - let cancelled = false; - (async () => { - try { - const doFetch = apiFetch ?? fetch; - const loadObjectSchema = async (name: string) => { - const r = await doFetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, { - headers: { accept: 'application/json' }, - credentials: 'include', - }); - const doc = await r.json().catch(() => null); - return doc?.item ?? doc?.data ?? doc; - }; - const objSchema = await loadObjectSchema(object); - // ONE walk for every dimension, memoized per call — sibling dimensions - // sharing a relationship prefix fetch that object once. - const metaByPath = await resolveDimensionFieldMeta( - objSchema, - dims.map(fieldOf), - loadObjectSchema, - ); - if (!cancelled) { - setOptionMeta({ metaByPath, relabel: dims.map((dim) => ({ dim, path: fieldOf(dim) })) }); - } - } catch { - // Best-effort by construction: a failed read leaves the rows exactly as - // the server sent them, which is what this surface rendered before. - if (!cancelled) setOptionMeta(null); - } - })(); - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [signature, apiFetch]); - return React.useMemo(() => { - if (!optionMeta) return null; - const { metaByPath, relabel } = optionMeta; - // One translator per resolved path, bound to the object that OWNS the - // terminal field — the relationship TARGET for a dotted path, not the - // dataset's base object, because that is the object the bundle key names. - const translatorFor = (path: string): OptionLabelTranslator | undefined => { - const meta = metaByPath[path]; - const owner = meta?.object; - if (!owner) return undefined; - return (value, authored) => fieldOptionLabel(owner, meta.field, value, authored); - }; - const labels: Record> = {}; - for (const { dim, path } of relabel) { - const m = buildDimensionLabelMap(metaByPath[path]?.options, translatorFor(path)); - if (m) labels[dim] = m; - } - return Object.keys(labels).length > 0 ? labels : null; - }, [optionMeta, fieldOptionLabel]); -} +export { useDatasetDimensionLabels, type DimensionLabelMaps } from '@object-ui/react'; diff --git a/packages/react/src/hooks/__tests__/useDatasetDimensionLabels.test.tsx b/packages/react/src/hooks/__tests__/useDatasetDimensionLabels.test.tsx new file mode 100644 index 000000000..923dce1d9 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useDatasetDimensionLabels.test.tsx @@ -0,0 +1,227 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `useDatasetDimensionLabels` / `useDatasetDimensionMeta` (objectui#4389) — the + * analytics label net's React glue, pinned at the seam where it is now stated + * ONCE for both consuming surfaces. + * + * ## What these pins are for, and what they deliberately do NOT re-assert + * + * The RENDERED behaviour of this net is already pinned per surface, by the 39 + * assertions PR #4388 landed across + * `plugin-dashboard/src/__tests__/DatasetWidget.*` and + * `plugin-report/src/__tests__/DatasetReportRenderer.localSelectI18n.test.tsx`. + * Those keep passing UNCHANGED across this refactor and are its acceptance + * evidence; nothing here duplicates them. + * + * What they CANNOT state is the property that only exists now that there is one + * hook: that both surfaces inherit the same two bug fixes from the same wiring. + * A plugin-level pin can only ever observe its own copy — which is exactly how + * the duplication survived long enough to be filed. So these four pins assert + * the wiring itself: + * + * 1. the read rides the host's AUTHENTICATED `apiFetch` (objectui#4121); + * 2. it degrades to the global `fetch` when no host supplies one, rather than + * crashing a surface rendered outside a provider; + * 3. a NEW `apiFetch` identity re-issues the read — i.e. `apiFetch` really is + * IN the effect's deps, which is the half of #4121 that a "does it call + * apiFetch once" assertion cannot see; + * 4. switching the language at RUNTIME re-labels in place and issues NO further + * metadata read — i.e. the locale really is OUT of the effect's deps, and + * the fetched metadata really is locale-free in state + * (objectui#4030 / PR #4324). + * + * Pin 4 is the one that would have caught the regression #4324 fixed, and it is + * the reason the fetch and the derivation sit on opposite sides of a memo. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, renderHook, act, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, createI18n } from '@object-ui/i18n'; +import { SchemaRendererProvider } from '../../context/SchemaRendererContext'; +import { useDatasetDimensionLabels, useDatasetDimensionMeta } from '../useDatasetDimensionLabels'; + +const CHANNEL_OPTIONS = [ + { value: 'domestic', label: 'Domestic' }, + { value: 'export', label: 'Export' }, +]; + +const OPPORTUNITY = { + name: 'crm_opportunity', + fields: { sales_channel: { type: 'select', options: CHANNEL_OPTIONS } }, +}; + +/** + * The same bundle shape the #4388 surface pins use. + * + * `fields` is NOT decoration: `useObjectLabel`'s namespace discovery only + * treats a top-level key as an app namespace when it carries one of + * `objects`/`fields`/`apps`/… , so a bundle with `fieldOptions` alone is never + * searched and every option silently falls back to its authored label. + */ +const ZH_BUNDLE = { + zh: { + crm: { + fields: { crm_opportunity: { sales_channel: '销售渠道' } }, + fieldOptions: { + crm_opportunity: { sales_channel: { domestic: '国内', export: '出口' } }, + }, + }, + }, +}; + +/** A `GET /meta/object/:name` router that counts what it was asked for. */ +function makeMetaRouter(docs: Record) { + const requested: string[] = []; + const fn = vi.fn(async (input: unknown) => { + const url = String(input); + const m = /\/api\/v1\/meta\/object\/(.+)$/.exec(url); + const name = m ? decodeURIComponent(m[1]) : ''; + requested.push(name); + const doc = docs[name]; + if (!doc) return { ok: false, json: async () => ({}) }; + return { ok: true, json: async () => ({ item: doc }) }; + }); + return { fn, requested }; +} + +const DIMENSION_FIELDS = { sales_channel: 'sales_channel' }; +const DIMENSIONS = ['sales_channel']; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('useDatasetDimensionMeta — the read rides the host apiFetch (objectui#4121)', () => { + it('reads through SchemaRendererContext.apiFetch, not the bare global fetch', async () => { + const host = makeMetaRouter({ crm_opportunity: OPPORTUNITY }); + const globalRouter = makeMetaRouter({ crm_opportunity: OPPORTUNITY }); + global.fetch = globalRouter.fn as any; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook( + () => useDatasetDimensionMeta('crm_opportunity', DIMENSION_FIELDS, DIMENSIONS), + { wrapper }, + ); + + await waitFor(() => expect(result.current).not.toBeNull()); + expect(host.requested).toEqual(['crm_opportunity']); + // The load must NOT have leaked onto the unauthenticated global channel. + expect(globalRouter.fn).not.toHaveBeenCalled(); + expect(result.current?.metaByPath['sales_channel']?.object).toBe('crm_opportunity'); + expect(result.current?.metaByPath['sales_channel']?.options).toEqual(CHANNEL_OPTIONS); + }); + + it('falls back to the global fetch when no host supplies apiFetch', async () => { + const globalRouter = makeMetaRouter({ crm_opportunity: OPPORTUNITY }); + global.fetch = globalRouter.fn as any; + + // No provider at all — the surface must degrade, not crash. (This is why + // the context is read directly rather than via a throwing useSchemaContext.) + const { result } = renderHook(() => + useDatasetDimensionMeta('crm_opportunity', DIMENSION_FIELDS, DIMENSIONS), + ); + + await waitFor(() => expect(result.current).not.toBeNull()); + expect(globalRouter.requested).toEqual(['crm_opportunity']); + }); + + it('re-issues the read when apiFetch changes identity — apiFetch IS in the deps', async () => { + const first = makeMetaRouter({ crm_opportunity: OPPORTUNITY }); + const second = makeMetaRouter({ crm_opportunity: OPPORTUNITY }); + + // Rendered as a real tree rather than via `renderHook`'s wrapper: the + // wrapper receives ONLY `children`, never `initialProps`, so a + // provider-prop-driven rerender cannot be expressed through it. The varying + // input here IS the provider's `apiFetch`, so the provider has to be the + // thing that rerenders. + const Probe = () => { + const meta = useDatasetDimensionMeta('crm_opportunity', DIMENSION_FIELDS, DIMENSIONS); + return
{meta ? 'resolved' : 'pending'}
; + }; + const treeWith = (apiFetch: unknown) => ( + + + + ); + + const { rerender, getByTestId } = render(treeWith(first.fn)); + + await waitFor(() => expect(getByTestId('probe').textContent).toBe('resolved')); + expect(first.requested).toEqual(['crm_opportunity']); + + rerender(treeWith(second.fn)); + + // THE PIN: a new channel re-reads. `apiFetch` missing from the deps would + // leave this at [] forever — the half of objectui#4121 that "was apiFetch + // called at least once" cannot see. + await waitFor(() => expect(second.requested).toEqual(['crm_opportunity'])); + // The first channel is not re-read. + expect(first.requested).toEqual(['crm_opportunity']); + }); +}); + +describe('useDatasetDimensionLabels — the metadata stays locale-free (objectui#4030 / PR #4324)', () => { + it('re-labels in place on a runtime language switch, with NO second metadata read', async () => { + const host = makeMetaRouter({ crm_opportunity: OPPORTUNITY }); + const i18n = createI18n({ + defaultLanguage: 'en', + detectBrowserLanguage: false, + resources: ZH_BUNDLE, + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + const { result } = renderHook( + () => useDatasetDimensionLabels('crm_opportunity', DIMENSION_FIELDS, DIMENSIONS), + { wrapper }, + ); + + // Under `en` the map is the PRE-#4030 value → authored-label map: the net's + // original job (objectui#4053) with no translation layered on. Waiting for + // this settled shape — rather than for the fetch alone — is what makes the + // "no second read" assertion below meaningful; asserting before the state + // lands would pass for the wrong reason. + await waitFor(() => + expect(result.current?.sales_channel).toEqual({ domestic: 'Domestic', export: 'Export' }), + ); + expect(host.requested).toEqual(['crm_opportunity']); + + await act(async () => { + await i18n.changeLanguage('zh'); + }); + + // The maps gain the translation — derived in the render memo from metadata + // ALREADY in state, keyed BOTH by the stored value and by the authored + // label, so a row arrives relabeled whichever spelling the server sent. + await waitFor(() => + expect(result.current?.sales_channel).toEqual({ + domestic: '国内', + Domestic: '国内', + export: '出口', + Export: '出口', + }), + ); + + // THE PIN: the language switch issued no further metadata read. A locale in + // the effect's deps would show up here as a second `crm_opportunity`. + expect(host.requested).toEqual(['crm_opportunity']); + }); +}); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 310bdb31c..c4a20dae7 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -41,6 +41,11 @@ export * from './useActionEngine'; // authored strings an action carries (label / confirmText / successMessage). export * from './useActionTextLocalizer'; export * from './useCapabilityGate'; +// The analytics label net's React glue, consumed by BOTH plugin-dashboard's +// `DatasetWidget` and plugin-report's dataset block (objectui#4389). It lives +// here rather than in `@object-ui/core` because it reads `SchemaRendererContext` +// — see the file header for the measured dependency direction. +export * from './useDatasetDimensionLabels'; export * from './useDataRefresh'; export * from './usePageAssignment'; export * from './useRecordSearch'; diff --git a/packages/react/src/hooks/useDatasetDimensionLabels.ts b/packages/react/src/hooks/useDatasetDimensionLabels.ts new file mode 100644 index 000000000..6cc39b5db --- /dev/null +++ b/packages/react/src/hooks/useDatasetDimensionLabels.ts @@ -0,0 +1,199 @@ +/** + * ObjectUI — useDatasetDimensionLabels + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * The analytics label net's REACT half (objectui#4389) — the fetch-and-memo + * glue that carries a dataset query's dimension metadata to the pure + * `@object-ui/core` helpers that decide what a label IS. + * + * ## What this is, and what it is not + * + * Nothing here decides a label. The resolution rules are all core's, and were + * never duplicated: `loadDimensionFieldMeta` → `resolveDimensionFieldMeta`, and + * `deriveDimensionLabelMaps` → `buildDimensionLabelMap`, with the translator + * bound by `dimensionOptionTranslator` to `{ns}.fieldOptions...` + * — the one convention list and form surfaces already translate select options + * through. What this hook owns is only the three things core structurally + * cannot: the host context read, the state, and the memo boundary between them. + * + * ## Why it lives HERE + * + * objectui#4389 filed the glue as duplicated between `plugin-dashboard`'s + * `DatasetWidget` and `plugin-report`'s `useDatasetDimensionLabels` (both + * created by PR #4388), and named `@object-ui/core` as its home. That home was + * retired by measurement, and the card's PM RULING #2 records the correction: + * `SchemaRendererContext` is defined in THIS package, which depends on + * `@object-ui/core`, so core importing it back is a cycle; core is also + * React-free by declaration, by content, and by AGENTS.md §3. This package is + * the one place that is downstream of core (the helpers are reachable), + * downstream of `@object-ui/i18n` (`useSafeFieldLabel` is reachable), the owner + * of `SchemaRendererContext` (`apiFetch` is a local read), and upstream of both + * consuming plugins. It already hosts this exact fetch-and-memo-off-`apiFetch` + * shape three times over — `useViewData`, `useElementDataSource`, `useDiscovery`. + * + * ## The two properties this hook exists to state ONCE + * + * Both are bug fixes that were previously written out on each surface, which is + * the drift objectui#4389 was filed to close: + * + * 1. **The read rides the host's AUTHENTICATED `apiFetch` (objectui#4121)**, so + * `apiFetch` is IN the effect's deps. A bearer-token session carries its + * credential in the `Authorization` header, not a cookie, so + * `credentials: 'include'` alone left this read unauthenticated in a hosted + * console — and because the net is best-effort, the symptom was not an error + * but option colours and dimension labels silently never applying. The + * context is consulted DIRECTLY rather than through a `useSchemaContext()` + * that throws, so a surface rendered outside a host keeps degrading to the + * global `fetch` instead of crashing the render. + * 2. **The fetched metadata stays LOCALE-FREE in state (objectui#4030 / + * PR #4324)**, so the locale is NOT in the effect's deps. The bundle is + * applied one memo down, in {@link useDatasetDimensionLabels}, which is what + * makes a language switch re-label in place instead of re-issuing the + * metadata read. + * + * Both are pinned in `useDatasetDimensionLabels.test.tsx`, which asserts them + * at this seam — the level the per-plugin pins cannot state, since neither + * plugin can observe that the OTHER one inherits the same wiring. + */ + +import { useContext, useEffect, useMemo, useState } from 'react'; +import { + deriveDimensionLabelMaps, + loadDimensionFieldMeta, + type DimensionFieldMeta, + type DimensionRelabelTarget, +} from '@object-ui/core'; +import { useSafeFieldLabel } from '@object-ui/i18n'; +import { SchemaRendererContext } from '../context/SchemaRendererContext'; + +/** `{ dimension → { rowValue → displayLabel } }`, or null when nothing resolved. */ +export type DimensionLabelMaps = Record> | null; + +/** What the metadata read resolved, kept locale-free (objectui#4030). */ +export interface DatasetDimensionMeta { + /** `{ fieldPath → { object, field, options } }` for the paths that resolved. */ + metaByPath: Record; + /** The dimensions this surface relabels, paired with their field paths. */ + relabel: DimensionRelabelTarget[]; +} + +/** Shared options for both hooks below. */ +export interface DatasetDimensionLabelOptions { + /** + * Set false to issue no read at all. The dashboard's METRIC branch passes + * this: it renders ONE measure value plus that measure's header label, so a + * dimension's value never reaches its output in any spelling and resolving + * would be a metadata read nothing consumes (objectui#4263, pinned). + */ + enabled?: boolean; +} + +/** + * Resolve one dataset query's dimension field metadata — the LOCALE-FREE half. + * + * Use this directly only when the surface derives more from the metadata than + * label maps; the dashboard does, for its chart-only per-category colours and + * declared category order. Everything else wants {@link useDatasetDimensionLabels}. + * + * @param object the dataset's base object, as the query result reported it + * @param dimensionFields the result's `dimension → field path` map (a dotted + * path resolves against the relationship TARGET, ADR-0071 multi-hop included) + * @param dimensions the dimension names this surface renders + */ +export function useDatasetDimensionMeta( + object: string | undefined, + dimensionFields: Record | undefined, + dimensions: readonly string[] | undefined, + options?: DatasetDimensionLabelOptions, +): DatasetDimensionMeta | null { + // The host's AUTHENTICATED fetch (objectui#4121) — see property 1 in the + // file header for why it is read off the context directly. + const apiFetch = useContext(SchemaRendererContext)?.apiFetch; + const enabled = options?.enabled ?? true; + + // LOCALE-FREE state (objectui#4030) — see property 2 in the file header. + const [meta, setMeta] = useState(null); + + const dims = (dimensions ?? []).filter(Boolean); + // A string signature, for the same reason `useDatasetRows` uses one: the + // dimension list and field map reach a renderer as arrays/objects rebuilt on + // every render, so keying the effect on their identity would refetch forever. + // NOTE what is deliberately ABSENT from it: the locale. That absence is + // property 2, and it is pinned. + const signature = [ + enabled ? '1' : '0', + object ?? '', + dims.join(','), + JSON.stringify(dimensionFields ?? null), + ].join('|'); + + useEffect(() => { + if (!enabled || !object || dims.length === 0) { + setMeta(null); + return; + } + const fieldOf = (dim: string) => (dimensionFields && dimensionFields[dim]) || dim; + let cancelled = false; + (async () => { + try { + const doFetch = apiFetch ?? fetch; + const loadObjectSchema = async (name: string) => { + const r = await doFetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, { + headers: { accept: 'application/json' }, + credentials: 'include', + }); + const doc = await r.json().catch(() => null); + return doc?.item ?? doc?.data ?? doc; + }; + // ONE walk for every dimension, memoized per call inside core — sibling + // dimensions sharing a relationship prefix read that object once, and + // the base object is seeded so it is never re-read. + const metaByPath = await loadDimensionFieldMeta(loadObjectSchema, object, dims.map(fieldOf)); + if (!cancelled) { + setMeta({ metaByPath, relabel: dims.map((dim) => ({ dim, path: fieldOf(dim) })) }); + } + } catch { + // Best-effort by construction: a failed read leaves the rows exactly as + // the server sent them, which is what these surfaces rendered before. + if (!cancelled) setMeta(null); + } + })(); + return () => { + cancelled = true; + }; + // `apiFetch` joins the deps (objectui#4121); the locale does NOT + // (objectui#4030 / PR #4324). `apiFetch` comes from the provider's + // memoized context value, and this hook's own `setState` cannot re-render + // the provider, so its identity is stable across the effect's own updates. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature, apiFetch]); + + return meta; +} + +/** + * Resolve the `{ dimension → { value → displayLabel } }` maps for one dataset + * query's dimensions, with the locale bundle applied. + * + * The full glue: {@link useDatasetDimensionMeta} for the locale-free read, then + * core's `deriveDimensionLabelMaps` in a render memo. Feed the result to + * `relabelDimensions`. A language switch re-runs only the memo. + * + * Parameters are as {@link useDatasetDimensionMeta}. + */ +export function useDatasetDimensionLabels( + object: string | undefined, + dimensionFields: Record | undefined, + dimensions: readonly string[] | undefined, + options?: DatasetDimensionLabelOptions, +): DimensionLabelMaps { + const { fieldOptionLabel } = useSafeFieldLabel(); + const meta = useDatasetDimensionMeta(object, dimensionFields, dimensions, options); + return useMemo( + () => deriveDimensionLabelMaps(meta?.metaByPath, meta?.relabel, fieldOptionLabel), + [meta, fieldOptionLabel], + ); +}