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
7 changes: 7 additions & 0 deletions .changeset/injected-system-column-labels-localised.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@objectstack/spec": patch
---

The tenant-scope and owning-business-unit system columns now render a localised display name on the `/meta` read exits, as the other platform-injected columns already did.

`translateObject` carries a built-in label table for the columns the platform injects onto every eligible object, applied while a column still carries its injected English default, so a `zh-CN` / `ja-JP` / `es-ES` request never sees the English label on a custom object that ships no translation entries of its own. The table covered `owner_id`, `created_at`, `created_by`, `updated_at` and `updated_by` but not the two remaining injected columns, `organization_id` (`Organization`) and `owning_business_unit_id` (`Owning Business Unit`), so those two leaked English on every locale. Both rows are added, with the wording the platform bundles already use for the same columns on platform objects. The identity-stable column definitions are untouched, no new authorable key is introduced, and a label a tenant or author customised is still never overridden.
202 changes: 202 additions & 0 deletions packages/rest/src/meta-object-injected-column-i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14972 — every platform-injected system column reaches the `/meta/object`
* reads with a localised display name.
*
* The RULE lives in `@objectstack/spec/system` (`translateObject`'s built-in
* system-field label table, unit-tested per column and per shipped locale in
* `i18n-resolver.test.ts`). What can only be tested here is the SEAM: the
* protocol's read exits inject the columns (`applyInjectedSystemColumns`)
* BEFORE this boundary translates the document, and the boundary translates
* even when the tenant's bundle carries nothing for the object — a custom
* object ships no per-object entries for columns it never declared, so the
* built-in table is the only thing that can answer. The served document
* below therefore spreads the columns from the provenance module's own
* definitions, exactly as the protocol's injection does, and the bundle names
* a different object on purpose.
*
* Every injected column is named in the assertion: the defect was two rows
* missing from a table of seven, and a loop over whatever the table happens to
* carry would have been green with them missing.
*/

import { describe, it, expect, vi } from 'vitest';
import { injectedSystemColumnDefs } from '@objectstack/spec/data';
import { RestServer } from './rest-server.js';

// ---------------------------------------------------------------------------
// Fixtures — one custom object, every injected column, a bundle that knows
// another object
// ---------------------------------------------------------------------------

const INJECTED = injectedSystemColumnDefs({ name: 'contracts', fields: { title: { type: 'text' } } });

/** What the protocol serves: the author's field plus the injected columns. */
const SERVED = {
name: 'contracts',
label: 'Contract',
fields: {
title: { name: 'title', type: 'text', label: 'Title' },
...INJECTED,
},
};

const BUNDLE: Record<string, any> = {
'zh-CN': { objects: { showcase_contact: { label: '联系人' } } },
};

const i18nService = {
getLocales: () => ['en', 'zh-CN'],
getTranslations: (locale: string) => BUNDLE[locale],
getDefaultLocale: () => 'en',
};

// ---------------------------------------------------------------------------
// Doubles
// ---------------------------------------------------------------------------

function mockServer() {
return {
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
};
}

function mockRes() {
return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() };
}

function protocol() {
return {
getDiscovery: vi.fn().mockResolvedValue({
version: 'v0',
routes: { data: '', metadata: '', ui: '', auth: '/auth' },
}),
getMetaTypes: vi.fn().mockResolvedValue([]),
getMetaItems: vi.fn(async ({ type }: any) => (type === 'object' || type === 'objects' ? [SERVED] : [])),
getMetaItem: vi.fn(async ({ type, name }: any) => ({
type: type === 'objects' ? 'object' : type,
name,
item: SERVED,
lock: 'none',
editable: true,
})),
getMetaItemCached: undefined as any,
findData: vi.fn().mockResolvedValue([]),
};
}

function makeRest() {
const rest = new RestServer(
mockServer() as any, protocol() as any, { api: { requireAuth: false } } as any,
undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined,
// i18nServiceProvider — the 14th constructor argument.
async () => i18nService as any,
);
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] });
rest.registerRoutes();
return rest;
}

function routeFor(rest: RestServer, path: string) {
const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path);
if (!route) throw new Error(`route not registered: GET ${path}`);
return route;
}

/** The body of the last `res.json(...)` (indexed: this package's `lib` target predates `.at`). */
function lastBody(res: ReturnType<typeof mockRes>): any {
const calls = res.json.mock.calls;
return calls.length ? calls[calls.length - 1][0] : undefined;
}

async function itemFields(locale: string): Promise<Record<string, any>> {
const res = mockRes();
await routeFor(makeRest(), '/api/v1/meta/:type/:name').handler(
{
method: 'GET',
params: { type: 'object', name: 'contracts' },
query: {},
body: {},
headers: { 'accept-language': locale },
},
res,
);
return lastBody(res)?.item?.fields;
}

async function listFields(locale: string): Promise<Record<string, any>> {
const res = mockRes();
await routeFor(makeRest(), '/api/v1/meta/:type').handler(
{ method: 'GET', params: { type: 'object' }, query: {}, body: {}, headers: { 'accept-language': locale } },
res,
);
const body = lastBody(res);
const items = Array.isArray(body) ? body : body?.items ?? [];
return items[0]?.fields;
}

const ZH_CN = {
organization_id: '组织',
created_at: '创建时间',
created_by: '创建人',
updated_at: '更新时间',
updated_by: '更新人',
owner_id: '所有者',
owning_business_unit_id: '所属业务单元',
};

function labelsOf(fields: Record<string, any>): Record<string, unknown> {
return {
organization_id: fields.organization_id?.label,
created_at: fields.created_at?.label,
created_by: fields.created_by?.label,
updated_at: fields.updated_at?.label,
updated_by: fields.updated_by?.label,
owner_id: fields.owner_id?.label,
owning_business_unit_id: fields.owning_business_unit_id?.label,
};
}

// ---------------------------------------------------------------------------
// The seam
// ---------------------------------------------------------------------------

describe('#14972 — injected system columns reach the /meta/object reads localised', () => {
it('the fixture spreads all seven injected columns with their shipped English labels', () => {
expect(Object.keys(INJECTED).sort()).toEqual([
'created_at', 'created_by', 'organization_id', 'owner_id',
'owning_business_unit_id', 'updated_at', 'updated_by',
]);
expect((SERVED.fields as any).organization_id.label).toBe('Organization');
});

it('by-name read: every injected column answers Chinese on a zh-CN request', async () => {
const fields = await itemFields('zh-CN');
expect(labelsOf(fields)).toEqual(ZH_CN);
// The author's own field is untouched: the bundle carries nothing for it.
expect(fields.title.label).toBe('Title');
});

it('list read: every injected column answers Chinese on a zh-CN request', async () => {
const fields = await listFields('zh-CN');
expect(labelsOf(fields)).toEqual(ZH_CN);
expect(fields.title.label).toBe('Title');
});

it('an en request keeps the shipped English defaults on both reads', async () => {
for (const fields of [await itemFields('en'), await listFields('en')]) {
expect(labelsOf(fields)).toEqual({
organization_id: 'Organization',
created_at: 'Created At',
created_by: 'Created By',
updated_at: 'Last Modified At',
updated_by: 'Last Modified By',
owner_id: 'Owner',
owning_business_unit_id: 'Owning Business Unit',
});
}
});
});
116 changes: 116 additions & 0 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2212,6 +2212,122 @@ describe('translateObject system-field label fallback', () => {
});
});

// ────────────────────────────────────────────────────────────────────────────
// translateObject — EVERY platform-injected column, per shipped locale
// (objectstack#14972)
// ────────────────────────────────────────────────────────────────────────────

import { injectedSystemColumnDefs } from '../data/injected-system-column-provenance';

describe('translateObject localises every platform-injected column (objectstack#14972)', () => {
// The column definitions come from the provenance module itself — the same
// objects `applySystemFields` spreads at registration and the `/meta` read
// exits serve — so the English defaults this block starts from cannot drift
// from the shipped tables through a retyped label. The document is a custom
// object that ships no translation entries of its own, and the bundle is
// absent: only the built-in table can answer.
const injected = injectedSystemColumnDefs({ name: 'contracts', fields: { title: { type: 'text' } } });
const doc = {
name: 'contracts',
label: 'Contract',
fields: {
title: { name: 'title', type: 'text', label: '合同名称' },
...(injected as Record<string, any>),
},
};
const SHIPPED_LOCALES = ['en', 'zh-CN', 'ja-JP', 'es-ES'] as const;
const labelsFor = (locale: string): Record<string, string> => {
const out = translateObject(doc, undefined, { locale, fallbackChain: [locale] });
const fields = out.fields as Record<string, any>;
return Object.fromEntries(Object.keys(injected).map((name) => [name, fields[name].label]));
};

it('starts from all seven injected columns carrying their shipped English defaults', () => {
expect(Object.keys(injected).sort()).toEqual([
'created_at',
'created_by',
'organization_id',
'owner_id',
'owning_business_unit_id',
'updated_at',
'updated_by',
]);
// An `en` request leaves every label exactly as the definition ships it.
expect(labelsFor('en')).toEqual({
organization_id: 'Organization',
created_at: 'Created At',
created_by: 'Created By',
updated_at: 'Last Modified At',
updated_by: 'Last Modified By',
owner_id: 'Owner',
owning_business_unit_id: 'Owning Business Unit',
});
});

it('zh-CN: every injected column reads Chinese, in the platform bundles\' wording', () => {
expect(labelsFor('zh-CN')).toEqual({
organization_id: '组织',
created_at: '创建时间',
created_by: '创建人',
updated_at: '更新时间',
updated_by: '更新人',
owner_id: '所有者',
owning_business_unit_id: '所属业务单元',
});
});

it('ja-JP: every injected column reads Japanese, in the platform bundles\' wording', () => {
expect(labelsFor('ja-JP')).toEqual({
organization_id: '組織',
created_at: '作成日時',
created_by: '作成者',
updated_at: '更新日時',
updated_by: '更新者',
owner_id: '所有者',
owning_business_unit_id: '所属ビジネスユニット',
});
});

it('es-ES: every injected column reads Spanish, in the platform bundles\' wording', () => {
expect(labelsFor('es-ES')).toEqual({
organization_id: 'Organización',
created_at: 'Creado el',
created_by: 'Creado por',
updated_at: 'Actualizado el',
updated_by: 'Actualizado por',
owner_id: 'Propietario',
owning_business_unit_id: 'Unidad de negocio propietaria',
});
});

it('a tenant that relabelled the organization column keeps its label on every locale', () => {
// The guard is comparison-based: the built-in row applies only while the
// served label still equals the definition's English default. A label the
// tenant (or the author) wrote is authored data and wins on every locale,
// the `en` request included.
const renamed = {
...doc,
fields: {
...doc.fields,
organization_id: { ...(injected.organization_id as Record<string, any>), label: '所属公司' },
},
};
for (const locale of SHIPPED_LOCALES) {
const out = translateObject(renamed, undefined, { locale, fallbackChain: [locale] });
expect((out.fields as any).organization_id.label, locale).toBe('所属公司');
// The untouched columns still localise around it.
expect((out.fields as any).owner_id.label, locale).toBe(labelsFor(locale).owner_id);
}
});

it('never mutates the input document or the shipped definitions', () => {
labelsFor('zh-CN');
expect((doc.fields as any).organization_id.label).toBe('Organization');
expect((doc.fields as any).owning_business_unit_id.label).toBe('Owning Business Unit');
expect(injected.organization_id.label).toBe('Organization');
});
});

describe('translateObject inline actions (objectstack#3370)', () => {
// The `sys_approval_request` shape: decision actions declared inline on the
// object. The plugin ships `_actions` translations for them, but the object
Expand Down
37 changes: 31 additions & 6 deletions packages/spec/src/system/i18n-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2081,22 +2081,47 @@ function lookupObjectFieldOption(
}

/**
* Built-in labels for the platform-injected system fields (the ObjectQL
* registry stamps `owner_id` / `created_*` / `updated_*` onto every object
* with English labels). Custom objects carry no per-object translation
* entries for these, so without a fallback every localized surface — list
* headers, export files, import templates — leaks the English default (e.g.
* an otherwise fully-Chinese import template with an `Owner` column).
* Built-in labels for the platform-injected system fields — the full set
* `injectedSystemColumnDefs` (`../data/injected-system-column-provenance`)
* spreads onto every eligible object with English labels: the tenant scope
* anchor `organization_id`, the audit family `created_*` / `updated_*`,
* `owner_id` and `owning_business_unit_id`. Custom objects carry no
* per-object translation entries for these, so without a fallback every
* localized surface — list headers, export files, import templates, the
* `/meta` read exits — leaks the English default (e.g. an otherwise
* fully-Chinese import template with an `Owner` column, or an
* `Organization` field on every business object of a zh-CN tenant).
*
* The identity-stable definitions themselves are never localised in place:
* the ObjectQL registry and the served-document strip read them by exact
* identity, so display-name resolution is a read-exit concern that lives
* HERE, keyed by field name, and applies only while the served label still
* equals the definition's English default (see `builtinSystemFieldLabel`).
* A row's `en` therefore MUST equal the definition's `label` byte for byte
* — one that drifts silently stops matching and the column leaks English
* again; `i18n-resolver.test.ts` pins the pairing from the provenance
* module's own definitions.
*
* Wording matches the generated platform bundles (`*.objects.generated.ts`)
* so a system field reads the same on custom and platform objects.
* `owning_business_unit_id` has no bundle leaf of its own (injected, hidden,
* declared by no platform object), so its wording composes the bundles'
* `sys_business_unit` label with the ownership qualifier their
* `sys_user.primary_business_unit_id` leaves use.
*/
const SYSTEM_FIELD_LABELS: Record<string, Record<string, string>> = {
organization_id: { en: 'Organization', 'zh-CN': '组织', 'ja-JP': '組織', 'es-ES': 'Organización' },
owner_id: { en: 'Owner', 'zh-CN': '所有者', 'ja-JP': '所有者', 'es-ES': 'Propietario' },
created_at: { en: 'Created At', 'zh-CN': '创建时间', 'ja-JP': '作成日時', 'es-ES': 'Creado el' },
created_by: { en: 'Created By', 'zh-CN': '创建人', 'ja-JP': '作成者', 'es-ES': 'Creado por' },
updated_at: { en: 'Last Modified At', 'zh-CN': '更新时间', 'ja-JP': '更新日時', 'es-ES': 'Actualizado el' },
updated_by: { en: 'Last Modified By', 'zh-CN': '更新人', 'ja-JP': '更新者', 'es-ES': 'Actualizado por' },
owning_business_unit_id: {
en: 'Owning Business Unit',
'zh-CN': '所属业务单元',
'ja-JP': '所属ビジネスユニット',
'es-ES': 'Unidad de negocio propietaria',
},
};

/**
Expand Down
Loading