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
67 changes: 67 additions & 0 deletions .changeset/9092-inline-locale-declared-face.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
'@object-ui/types': minor
'@object-ui/plugin-grid': patch
---

Group A of objectui#7759: three declarations restated a label key as a plain
`string` and now state the spec's inline locale map (objectui#9092).

objectui#4580's revised Q1 ruling (option A) widened the label keys to
`string | I18nLabel` — a plain string **or** an inline per-locale map like
`{ en: 'Accounts', 'fr-FR': 'Comptes' }`, resolved by the spec's own
`resolveI18nLabel(label, locale)`. `BaseSchema` obeyed it on both faces. These
three restated the key on top of it:

- `AppComponentSchema.label` (`app.ts`)
- `ObjectGridSchema.label` and `.description` (`objectql.ts`)
- `PageNodeSchema.aria.ariaLabel` (`layout.ts`)

A restatement on an interface that extends `BaseSchema` is a **narrowing
override**, so each of these refused the map its own zod mirror accepted. The
mirrors needed no change: the first three inherit the zod `BaseSchema`'s
`I18nLabelSchema`, and the page node receives the spec's `AriaPropsSchema` by
reference. The defect was therefore declaration-only, and it sat on the side a
forward mirror-vs-declaration comparison reads as clean — an author following the
published ruling was refused by `tsc` while `safeParse` said yes.

`ObjectViewSchema.table` is `Partial<Pick<ObjectGridSchema, …>>` and picks up the
same repair mechanically.

**One runtime behaviour changes, at three sites.** `@object-ui/plugin-grid`'s
`ObjectGrid` put `schema.label` straight into three string positions — the
data-table caption, the export filename, and the record-detail overlay heading.
Restoring the declaration turned the first two into named compiler errors, which
is the audit the widening exists to produce: a map-valued label reached the
caption as an object and the export filename as `[object Object]`.

The third is the one a compiler cannot report, and it is worth knowing why. The
overlay heading goes through `t(key, options)`, whose options are
`Record<string, unknown>`, so the widening slips through an untyped sink and
nothing is flagged — while an unresolved map interpolates as the user-visible
heading `[object Object] Detail`, on the i18next path and on the provider-less
fallback alike. After a widening, `tsc` names the typed readers; the untyped
sinks (`t()` options, `String(…)`, template literals, `JSON.stringify`) have to
be found by hand.

All three now resolve through the spec's `resolveI18nLabel` against
`useDisplayLocale()`, matching the read sites that already did. Behaviour on the
string arm is unchanged, byte for byte. On the heading, a label that resolves to
nothing — an entry-less map, or an empty entry — falls through to the
`objectName` branch exactly as a missing label always did; testing the raw
`schema.label` could not do that, because every object is truthy.

⚠️ Not touched, deliberately: the **flat** `BaseSchema.ariaLabel`. It carries the
other vocabulary — objectui's keyed `{ key, defaultValue?, params? }` reference,
resolved by `resolveKeyedI18nLabel` — and objectui#4580 Q2-B withdrew the
`I18nLabel` spelling there as measured-wrong. The nested `aria.ariaLabel` widened
here is the inline form, which is what `@objectstack/spec`'s `AriaPropsSchema`
declares and what objectui#5134 made `ListView` resolve. The two object shapes are
structurally confusable to a reader, but **neither vocabulary admits the other**:
`InlineLocaleMapSchema` types its map with `key?: never; defaultValue?: never`,
and its `INLINE_LOCALE_KEY` pattern excludes both names, so writing one into the
other's slot is refused at `tsc` and at parse alike. (An earlier draft of this
note said each shape accepted the other vacuously — that was true when
objectui#4580 Q2-B wrote it, and the protocol has since closed it.) What a wrong
slot costs you is a wrong **answer** rather than a silent acceptance:
`resolveI18nLabel` hands a keyed reference back as its own `key` string. So still
check which resolver owns a slot before writing an object into it.
36 changes: 32 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ import { createSafeTranslation } from '@object-ui/i18n';
import { resolveGridCellRendering, gridCellRendererForFixedKey, BADGE_PREFIX_RENDERER_KEY } from './cellRendererResolution';
import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue } from '@object-ui/fields';
import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n';
// Two resolvers, two vocabularies — the repo spells the distinction into the
// NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own
// `resolveI18nLabel`: it resolves the INLINE per-locale map
// (`{ en: …, 'fr-FR': … }`) that `I18nLabel` carries. It does NOT accept
// objectui's keyed `{ key, defaultValue, params }` ref — that vocabulary lives
// on the FLAT `schema.ariaLabel` and is resolved by `SchemaRenderer` instead.
// Needed here since objectui#9092 restored `ObjectGridSchema.label` to the
// `string | I18nLabel` form `BaseSchema` has carried since objectui#4580: the
// two reads below put the label in STRING positions, so a map-valued label used
// to reach them as an object and the compiler could not say so.
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
import { stateMachineNextValues, isFieldInlineEditable } from './inline-edit-options';
import {
Badge, Button, NavigationOverlay, EmptyValue,
Expand Down Expand Up @@ -3205,7 +3216,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
prefix: exportConfig?.fileNamePrefix,
label: objectSchema?.label,
objectName: objectName || schema.objectName,
viewLabel: schema.label || schema.title,
viewLabel: resolveInlineI18nLabel(schema.label, displayLocale) || schema.title,
});

// Server-streamed path: csv / xlsx / json via dataSource.exportDownload.
Expand Down Expand Up @@ -4288,7 +4299,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({

const dataTableSchema: ObjectGridDataTableSchema = {
type: 'data-table',
caption: schema.label || schema.title,
caption: resolveInlineI18nLabel(schema.label, displayLocale) || schema.title,
columns: orderedColumns,
data,
pagination: paginationEnabled,
Expand Down Expand Up @@ -4564,8 +4575,25 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `Contacts Detail` / `Record Detail`), including with no `I18nProvider`
// mounted — `createSafeTranslation`'s fallback interpolates `{{label}}` from
// `GRID_DEFAULT_TRANSLATIONS`.
const detailTitle = schema.label
? t('detail.recordDetailWithLabel', { label: schema.label })
//
// ⚠️ The label is RESOLVED before it reaches `t()` (objectui#9092). This is an
// UNTYPED sink: `t`'s options are `Record<string, unknown>`, so when
// `ObjectGridSchema.label` was restored to `string | I18nLabel` the compiler
// named the two `string`-typed reads above and said nothing about this one.
// Unresolved, an inline locale map interpolates as `[object Object]` on BOTH
// paths — i18next substitutes the raw value, and the provider-less
// `interpolateFallback` runs it through `String(v)` — and this value IS the
// overlay's visible heading (`NavigationOverlay title=`, below), so the
// failure is user-facing rather than diagnostic.
//
// The fallthrough is deliberate: `resolveI18nLabel` answers `undefined` for an
// entry-less map and `''` for an empty entry, and both are falsy, so a label
// that resolves to nothing lands on the `objectName` branch exactly as a
// missing label always did. Testing `schema.label` itself could not do that —
// every object is truthy, so an entry-less map used to take the label branch.
const resolvedDetailLabel = resolveInlineI18nLabel(schema.label, displayLocale);
const detailTitle = resolvedDetailLabel
? t('detail.recordDetailWithLabel', { label: resolvedDetailLabel })
: schema.objectName
? t('detail.recordDetailWithLabel', {
label: schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* 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.
*/

/**
* `ObjectGrid`'s record-detail overlay heading resolves an INLINE locale map
* label instead of stringifying it — objectui#9092.
*
* ── Why this site needed its own pin ──────────────────────────────────────
* objectui#9092 restored `ObjectGridSchema.label` to `string | I18nLabel`, the
* form `BaseSchema` has carried since objectui#4580. The other two reads in
* `ObjectGrid.tsx` that put the label in a string position were found because
* `tsc` NAMED them: the data-table `caption` and the export `viewLabel` are
* both `string`-typed sinks, so the widening surfaced them as compile errors.
*
* This one is different, and that is the transferable part: `detailTitle` hands
* the label to `createSafeTranslation`'s `t(key, options)`, whose options
* parameter is `Record<string, unknown>` (`i18n/src/useSafeTranslation.ts`). An
* `unknown`-typed sink SWALLOWS the diagnostic, so an inventory built from
* compiler errors cannot reach this site — it has to be found by hand. After a
* widening, `tsc` finds the typed readers; `t()` options, `String(…)`, template
* literals and `JSON.stringify` do not report.
*
* ── What goes wrong when it is missed ─────────────────────────────────────
* `detailTitle` is handed to `NavigationOverlay`'s `title` prop (three call
* sites in `ObjectGrid.tsx`), which means it IS the visible heading of the
* record-detail drawer/modal/split/popover — not a diagnostic, not a log line.
* An unresolved map interpolates as `[object Object]`, on BOTH i18n paths:
* i18next substitutes the raw value into `'{{label}} Detail'`, and the
* provider-less fallback runs it through `String(v)`
* (`i18n/src/fallbackInterpolation.ts`). So the user-visible heading reads
* `[object Object] Detail`.
*
* ── Direction of these assertions (red-first) ─────────────────────────────
* The map cases were RED before the fix (`[object Object] Detail`) and are
* GREEN after (`Accounts Detail` / `联系人详情`). The STRING cases were GREEN
* before AND after: they are the control that must not move — resolving a plain
* string through `resolveI18nLabel` returns it unchanged, so English (and every
* other) output on the string arm is byte-identical to what objectui#3426 left.
*
* The provider-LESS half of the same fix is asserted in
* `ObjectGrid.overlayTitleNoProviderFallback.test.tsx`. It cannot live in this
* file: `createI18n` registers its instance as react-i18next's module-global
* default and that registration survives `cleanup()`, so a "no provider" render
* here would silently resolve against whichever locale a previous test mounted.
*/

import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import type { ObjectGridSchema } from '@object-ui/types';
import { ObjectGrid } from '../ObjectGrid';

registerAllFields();

/**
* Typed against the DECLARED face on purpose. `tsc -p tsconfig.test.json` reads
* this file, so re-narrowing `ObjectGridSchema.label` back to a plain `string`
* fails the type half of this pin as well as the runtime half.
*/
type GridLabel = NonNullable<ObjectGridSchema['label']>;

const MAP_LABEL: GridLabel = { en: 'Accounts', zh: '联系人' };
const STRING_LABEL: GridLabel = 'Accounts';
/** An entry-less map resolves to `undefined` — the `objectName` branch must take over. */
const ENTRYLESS_MAP: GridLabel = {};
/** An empty entry resolves to `''` — falsy, so the same fallthrough applies. */
const EMPTY_ENTRY_MAP: GridLabel = { en: '' };

const rows = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
];

function renderGridIn(language: string, schemaExtra: Record<string, unknown>) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<ActionProvider>
<ObjectGrid
schema={{
type: 'object-grid',
objectName: 'contacts',
columns: [{ field: 'name', label: 'Name' }],
data: { provider: 'value', items: rows },
navigation: { mode: 'drawer' },
...schemaExtra,
} as never}
/>
</ActionProvider>
</I18nProvider>,
);
}

/** Open the detail overlay the way a user does: click a row. */
async function openOverlay() {
const cell = await screen.findByText('Alice');
fireEvent.click(cell);
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
}

afterEach(() => cleanup());

describe('ObjectGrid record-detail overlay heading — inline locale map label (objectui#9092)', () => {
it('resolves the map against the display locale instead of stringifying it', async () => {
renderGridIn('en', { label: MAP_LABEL });
await openOverlay();

expect(screen.getByText('Accounts Detail')).toBeInTheDocument();
// The defect this pin exists to catch, spelled out rather than implied.
expect(screen.queryByText('[object Object] Detail')).toBeNull();
});

it('CONTROL — a plain-string label renders the same bytes it always did', async () => {
renderGridIn('en', { label: STRING_LABEL });
await openOverlay();

expect(screen.getByText('Accounts Detail')).toBeInTheDocument();
expect(screen.queryByText('[object Object] Detail')).toBeNull();
});

it('reads the session locale, not a hard-coded `en` arm', async () => {
// Proves the resolver is wired to `useDisplayLocale()`: the same map picks
// its zh entry, and the zh bundle's own word order (`{{label}}详情`) applies.
renderGridIn('zh', { label: MAP_LABEL });
await openOverlay();

expect(screen.getByText('联系人详情')).toBeInTheDocument();
expect(screen.queryByText('[object Object]详情')).toBeNull();
});

it('falls through to the capitalized objectName when the map resolves to nothing', async () => {
// An entry-less map is the one input `resolveI18nLabel` answers `undefined`
// for. The old `schema.label ? …` test was TRUTHY for it (any object is),
// so the heading would have interpolated an empty-ish object; the branch
// must land on `objectName` exactly as a missing label always did.
renderGridIn('en', { label: ENTRYLESS_MAP });
await openOverlay();

expect(screen.getByText('Contacts Detail')).toBeInTheDocument();
});

it('falls through to the capitalized objectName when the entry is empty', async () => {
renderGridIn('en', { label: EMPTY_ENTRY_MAP });
await openOverlay();

expect(screen.getByText('Contacts Detail')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@
* it red by rendering the raw key `detail.recordDetailWithLabel`, which is
* precisely the regression it exists to catch.
*
* ── The second describe block has the opposite direction (objectui#9092) ──
* The `inline locale map label` block below was RED before objectui#9092's grid
* fix and is GREEN after. It belongs in THIS file rather than in a third one
* because the defect is path-specific: a map label reaches `[object Object]`
* through TWO different interpolators, i18next's and this file's provider-less
* `interpolateFallback` (`i18n/src/fallbackInterpolation.ts`, `String(v)`), and
* a pin that exercised only the provider path would leave the `String(v)` arm
* unmeasured. The file-splitting rule below is what makes this arm reachable at
* all, so it is a reason to keep that rule, not an exception to it.
*
* ── Why this is its own FILE, not a describe block ────────────────────────
* `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next`
* registers that instance as **react-i18next's module-global default**. The
Expand All @@ -49,10 +59,14 @@ import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/re
import '@testing-library/jest-dom';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import type { ObjectGridSchema } from '@object-ui/types';
import { ObjectGrid } from '../ObjectGrid';

registerAllFields();

/** Typed against the DECLARED face — see the sibling file's note on why. */
type GridLabel = NonNullable<ObjectGridSchema['label']>;

const rows = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
Expand Down Expand Up @@ -106,3 +120,34 @@ describe('ObjectGrid overlay heading — English fallback with no provider (obje
expect(screen.queryByText('detail.recordDetail')).toBeNull();
});
});

describe('ObjectGrid overlay heading — inline locale map label, no provider (objectui#9092)', () => {
it('resolves the map before interpolation, never `String(v)`s it', async () => {
const label: GridLabel = { en: 'Accounts', zh: '联系人' };
renderGrid({ objectName: 'contacts', label });
await openOverlay();

// `interpolateFallback` runs each value through `String(v)`. Unresolved,
// this heading reads `[object Object] Detail` — user-visible chrome.
expect(screen.getByText('Accounts Detail')).toBeInTheDocument();
expect(screen.queryByText('[object Object] Detail')).toBeNull();
expect(screen.queryByText('detail.recordDetailWithLabel')).toBeNull();
});

it('CONTROL — a plain-string label renders the same bytes it always did', async () => {
const label: GridLabel = 'Accounts';
renderGrid({ objectName: 'contacts', label });
await openOverlay();

expect(screen.getByText('Accounts Detail')).toBeInTheDocument();
expect(screen.queryByText('[object Object] Detail')).toBeNull();
});

it('falls through to the capitalized objectName when the map resolves to nothing', async () => {
const label: GridLabel = {};
renderGrid({ objectName: 'contacts', label });
await openOverlay();

expect(screen.getByText('Contacts Detail')).toBeInTheDocument();
});
});
Loading
Loading