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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/30285-studio-translation-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(app-shell): Studio edits a translation item as a source-to-translation table with its gaps counted

Clause-②: no
Metadata-protocol: none

A `translation` item in Studio now opens on a table for its language: a row per string the package declares (objects, fields, options, apps, navigation, views, actions, dashboards and the rest), the source text beside the translation, grouped by item, with a count such as "37 of 120 untranslated" and an "Untranslated only" filter. The rows come from `collectExpectedEntries` in `@objectstack/lint`, the walk `os i18n extract` and `os lint` use, over the package's declared documents. The platform objects the package points at (for example the user object behind an owner lookup) are included, because their labels ship in a few languages only. A row also counts as translated when another bundle already serves the language at its key.

- Saving writes the same `translation` item to the same place as before (the `.custom` pair on a read-only package): each cell writes the item's JSON at its key. The item's label, language and description stay in the form above the table, and its JSON stays available as an advanced view for what the table does not list (messages, settings UI, metadata forms).
- The "no custom renderer, editing as JSON" notice no longer appears on this page.
- The metadata item list labels an item in a code package's `.custom` pair as customer-written, not as shipped in a code package.
- Labels in English and Chinese.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#30285 — the item list's source label. A translation written into
* a code package's `.custom` pair (ADR-0146 D2) is what a customer wrote, not
* what the code package shipped.
*/

import { describe, expect, it } from 'vitest';
import { classifyProvenance } from './ResourceListPage';

describe('classifyProvenance', () => {
it("labels an item in a code package's .custom pair customer-written", () => {
expect(classifyProvenance({ _packageId: 'com.example.crm.custom' })).toBe('runtime');
});

it('keeps an item of the code package itself labelled as shipped in code', () => {
expect(classifyProvenance({ _packageId: 'com.example.crm', _provenance: 'package' })).toBe('artifact');
});

it('reads only a real pair id as a pair: a doubled or bare suffix is not one', () => {
expect(classifyProvenance({ _packageId: 'com.example.crm.custom.custom' })).toBe('artifact');
expect(classifyProvenance({ _packageId: '.custom' })).toBe('artifact');
});

it("keeps the server's own classification first", () => {
expect(classifyProvenance({ _packageId: 'com.example.crm.custom' }, 'code')).toBe('artifact');
expect(classifyProvenance({ _packageId: 'com.example.crm' }, 'overlay')).toBe('runtime');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import { t, tFormat, translateMetadataType, useMetadataLocale } from './i18n.js'
import { buildPackageScopeOptions } from './package-scope.js';
import { ENVIRONMENT_SCOPE_QUERY, isEnvironmentScope, useWalledCatalogReadOnly } from './catalog-scope.js';
import { useMetadataAreaHost } from './metadataAreaHost.js';
import { customizationPackageId } from '@objectstack/spec/kernel';
import { customizationPackageId, isCustomizationPackage } from '@objectstack/spec/kernel';
import { CustomizationMark, usePackageCustomizations } from '../studio-design/CustomizationMark.js';
import {
customizationBaseOfRow,
Expand Down Expand Up @@ -131,11 +131,14 @@ type ItemRow = {
* tags objects with the synthetic packageId 'sys_metadata' (see
* framework protocol.ts:3092); treat that sentinel as runtime-authored.
*/
function classifyProvenance(item: Record<string, unknown>, rawSource?: string): 'artifact' | 'runtime' {
export function classifyProvenance(item: Record<string, unknown>, rawSource?: string): 'artifact' | 'runtime' {
if (rawSource === 'overlay' || rawSource === 'runtime') return 'runtime';
if (rawSource === 'code' || rawSource === 'artifact') return 'artifact';
const pkg = item._packageId as string | undefined;
if (!pkg || pkg === 'sys_metadata') return 'runtime';
// objectstack#30285 — a code package's `.custom` pair (ADR-0146 D2) holds
// what the customer wrote over the package, never what shipped in code.
if (isCustomizationPackage(pkg)) return 'runtime';
return 'artifact';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,24 @@ import {
type CustomizeBlock,
} from './StudioCustomizeControls.js';
import type { StudioBaseCustomization } from './StudioDesignSurface.js';
import { StudioTranslationTable } from './StudioTranslationTable.js';

/**
* objectstack#30285 — what the form still offers for a translation item: its
* identity and language. Its translation groups are the table's, below it.
*/
const TRANSLATION_FORM_KEYS = new Set(['label', 'locale', 'description']);

/** The keys the form never offers: the identity, and the declaration Customize writes. */
function hiddenFieldsOf(type: string): string[] {
function hiddenFieldsOf(type: string, schema?: Record<string, unknown>): string[] {
// An email template's locale is its member: an edit of it would write another member.
return type === 'email_template' ? ['name', 'locale', 'overrides'] : ['name', 'overrides'];
if (type === 'email_template') return ['name', 'locale', 'overrides'];
if (type === 'translation') {
const properties = schema?.properties;
const keys = properties && typeof properties === 'object' ? Object.keys(properties) : [];
return ['name', 'overrides', ...keys.filter((key) => key !== 'name' && key !== 'overrides' && !TRANSLATION_FORM_KEYS.has(key))];
}
return ['name', 'overrides'];
}

/** `doc` without its top-level `_` keys: the read's decorations are not the item's. */
Expand Down Expand Up @@ -246,7 +259,10 @@ export function StudioBundleItemEditor({
const schema =
(entry?.schema as Record<string, unknown> | undefined) ??
(config.defaultSchema as Record<string, unknown> | undefined);
const Preview = useRegisteredMetadataPreview(type);
// objectstack#30285 — a translation's table shows its source text and gaps itself.
const isTranslation = type === 'translation';
const RegisteredPreview = useRegisteredMetadataPreview(type);
const Preview = isTranslation ? null : RegisteredPreview;
// objectstack#30064 — a type with a designer of its own (a dataset) is edited in it, not in the form.
const Designer = useRegisteredMetadataDefaultInspector(type);
// What the designer reports as blocking (a dimension with no field): held, never sent.
Expand Down Expand Up @@ -639,11 +655,22 @@ export function StudioBundleItemEditor({
form={entry?.form}
value={draft}
onChange={onChange}
hiddenFields={hiddenFieldsOf(type)}
hiddenFields={hiddenFieldsOf(type, schema)}
fieldOrder={config.fieldOrder}
readOnly={!editable}
/>
)}
{isTranslation && (
<div className="mt-4">
<StudioTranslationTable
packageId={packageId}
draft={draft}
onChange={onChange}
readOnly={!editable}
locale={locale}
/>
</div>
)}
</div>
{Preview && (
<div className="min-w-0 border-t lg:overflow-auto lg:border-l lg:border-t-0" data-testid="studio-bundle-editor-preview">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#30285 — Studio's translation table, mounted over a package read
* through the metadata client and the language served through the SDK.
*
* Pinned:
* - a row per declared string, grouped by item, with its source text, the
* platform object the package points at (`sys_user`) included;
* - the count of what is untranslated, and the filter that shows only that;
* - what the language serves from another bundle counts as translated, and
* an answer in another (fallback) language does not;
* - typing a translation hands the item back with the value at the row's key;
* - no "no custom renderer, editing as JSON" notice: JSON is the advanced view.
*/

import * as React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';

const DECLARED: Record<string, Array<Record<string, unknown>>> = {
object: [
{
name: 'crm_account',
label: 'Account',
fields: {
tier: { type: 'select', label: 'Tier', options: [{ value: 'gold', label: 'Gold' }] },
owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' },
},
},
],
app: [{ name: 'crm', label: 'CRM', navigation: [] }],
};
const PLATFORM: Record<string, Record<string, unknown>> = { sys_user: { name: 'sys_user', label: 'User', fields: {} } };

const mockClient = {
list: vi.fn(async (type: string) => DECLARED[type] ?? []),
layered: vi.fn(async (type: string, name: string, options?: { packageId?: string }) => {
const doc = options?.packageId ? (DECLARED[type] ?? []).find((d) => d.name === name) : PLATFORM[name];
return { code: doc ?? null, effective: doc ?? null };
}),
};
let servedAnswer: { locale: string; translations: Record<string, unknown> } = { locale: 'vi-VN', translations: {} };
const getTranslations = vi.fn(async () => servedAnswer);

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient };
});
vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
const adapter = { getClient: () => ({ i18n: { getTranslations } }) };
return { ...mod, useAdapter: () => adapter };
});

import { StudioTranslationTable } from './StudioTranslationTable';

function Harness({ initial, onSeen }: { initial: Record<string, unknown>; onSeen?: (d: Record<string, unknown>) => void }) {
const [draft, setDraft] = React.useState(initial);
return (
<StudioTranslationTable
packageId="com.example.crm"
draft={draft}
onChange={(next) => {
onSeen?.(next);
setDraft(next);
}}
readOnly={false}
locale="en"
/>
);
}

const rowFor = (key: string) => document.querySelector(`[data-testid="studio-translation-row"][data-key="${key}"]`) as HTMLElement;

beforeEach(() => {
servedAnswer = { locale: 'vi-VN', translations: {} };
mockClient.list.mockClear();
mockClient.layered.mockClear();
});

describe('StudioTranslationTable', () => {
it('lists the declared strings by item, the platform object pointed at included, with the untranslated count', async () => {
render(<Harness initial={{ name: 'crm_vi', locale: 'vi-VN', objects: { crm_account: { label: 'Tài khoản' } } }} />);
const count = await screen.findByTestId('studio-translation-count');
const groups = screen.getAllByTestId('studio-translation-group').map((g) => g.getAttribute('data-group'));
expect(groups).toEqual(expect.arrayContaining(['objects.crm_account', 'objects.sys_user', 'apps.crm']));
expect(within(rowFor('objects.sys_user.label')).getByText('User')).toBeTruthy();
expect(within(rowFor('objects.crm_account.fields.tier.options.gold')).getByText('Gold')).toBeTruthy();
expect(rowFor('objects.crm_account.label').getAttribute('data-state')).toBe('here');
const total = Number(count.getAttribute('data-total'));
expect(Number(count.getAttribute('data-untranslated'))).toBe(total - 1);
expect(count.textContent).toBe(`${total - 1} of ${total} untranslated`);
expect(document.body.textContent).not.toMatch(/custom renderer|JSON editing/i);
});

it('shows only the untranslated rows when filtered', async () => {
render(<Harness initial={{ name: 'crm_vi', locale: 'vi-VN', objects: { crm_account: { label: 'Tài khoản' } } }} />);
await screen.findByTestId('studio-translation-count');
fireEvent.click(screen.getByTestId('studio-translation-only-missing'));
const states = screen.getAllByTestId('studio-translation-row').map((r) => r.getAttribute('data-state'));
expect(states.length).toBeGreaterThan(0);
expect(new Set(states)).toEqual(new Set(['missing']));
expect(rowFor('objects.crm_account.label')).toBeNull();
});

it('counts what the language serves from another bundle as translated, but not a fallback language', async () => {
servedAnswer = { locale: 'vi-VN', translations: { objects: { sys_user: { label: 'Người dùng' } } } };
const { unmount } = render(<Harness initial={{ name: 'crm_vi', locale: 'vi-VN' }} />);
await waitFor(() => expect(rowFor('objects.sys_user.label')?.getAttribute('data-state')).toBe('elsewhere'));
unmount();

servedAnswer = { locale: 'en', translations: { objects: { sys_user: { label: 'User' } } } };
render(<Harness initial={{ name: 'crm_vi', locale: 'vi-VN' }} />);
await screen.findByTestId('studio-translation-count');
await waitFor(() => expect(getTranslations).toHaveBeenCalledWith('vi-VN'));
expect(rowFor('objects.sys_user.label').getAttribute('data-state')).toBe('missing');
});

it("hands the item back with the typed translation at the row's key", async () => {
const seen = vi.fn();
render(<Harness initial={{ name: 'crm_vi', label: 'Tiếng Việt', locale: 'vi-VN' }} onSeen={seen} />);
await screen.findByTestId('studio-translation-count');
fireEvent.change(within(rowFor('objects.sys_user.label')).getByTestId('studio-translation-input'), {
target: { value: 'Người dùng' },
});
expect(seen).toHaveBeenLastCalledWith({
name: 'crm_vi',
label: 'Tiếng Việt',
locale: 'vi-VN',
objects: { sys_user: { label: 'Người dùng' } },
});
expect(rowFor('objects.sys_user.label').getAttribute('data-state')).toBe('here');
});

it('keeps the JSON as an advanced view of the translation groups', async () => {
render(<Harness initial={{ name: 'crm_vi', locale: 'vi-VN', messages: { hello: 'Xin chào' } }} />);
await screen.findByTestId('studio-translation-count');
const json = screen.getByTestId('studio-translation-json-input') as HTMLTextAreaElement;
expect(JSON.parse(json.value)).toEqual({ messages: { hello: 'Xin chào' } });
});
});
Loading
Loading