diff --git a/.changeset/30285-studio-translation-table.md b/.changeset/30285-studio-translation-table.md new file mode 100644 index 00000000000..a2edc2ff88c --- /dev/null +++ b/.changeset/30285-studio-translation-table.md @@ -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. diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.sourceLabel-30285.test.ts b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.sourceLabel-30285.test.ts new file mode 100644 index 00000000000..e151a720131 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.sourceLabel-30285.test.ts @@ -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'); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index 1e2eca28697..246854dbd61 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -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, @@ -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, rawSource?: string): 'artifact' | 'runtime' { +export function classifyProvenance(item: Record, 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'; } diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx index 8500a768810..18109a11b66 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx @@ -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[] { // 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. */ @@ -246,7 +259,10 @@ export function StudioBundleItemEditor({ const schema = (entry?.schema as Record | undefined) ?? (config.defaultSchema as Record | 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. @@ -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 && ( +
+ +
+ )} {Preview && (
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioTranslationTable.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioTranslationTable.test.tsx new file mode 100644 index 00000000000..32dc69a2506 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioTranslationTable.test.tsx @@ -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>> = { + 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> = { 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 } = { locale: 'vi-VN', translations: {} }; +const getTranslations = vi.fn(async () => servedAnswer); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient }; +}); +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + const adapter = { getClient: () => ({ i18n: { getTranslations } }) }; + return { ...mod, useAdapter: () => adapter }; +}); + +import { StudioTranslationTable } from './StudioTranslationTable'; + +function Harness({ initial, onSeen }: { initial: Record; onSeen?: (d: Record) => void }) { + const [draft, setDraft] = React.useState(initial); + return ( + { + 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(); + 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(); + 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(); + await waitFor(() => expect(rowFor('objects.sys_user.label')?.getAttribute('data-state')).toBe('elsewhere')); + unmount(); + + servedAnswer = { locale: 'en', translations: { objects: { sys_user: { label: 'User' } } } }; + render(); + 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(); + 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(); + 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' } }); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioTranslationTable.tsx b/ui/packages/app-shell/src/views/studio-design/StudioTranslationTable.tsx new file mode 100644 index 00000000000..107baf5ed10 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioTranslationTable.tsx @@ -0,0 +1,448 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30285 — the translation item's editor in Studio: one language, + * a row per string the package declares, its source text beside the + * translation, a filter for what is still untranslated and a count of it. The + * rows and what counts as translated are `translationTable.ts`'s. + * + * Every edit writes the item's own JSON at the row's key, the shape the JSON + * editor writes, so saving sends the same `translation` item to the same + * place: this editor changes how the item is edited, not what is stored + * (ADR-0146 D2). What the table does not list stays editable as JSON below it. + */ + +import * as React from 'react'; +import { Input, Switch, Textarea, cn } from '@object-ui/components'; +import { useAdapter } from '@object-ui/react'; +import { customizationBasePackageId } from '@objectstack/spec/kernel'; +import type { ExpectedEntry } from '@objectstack/lint'; +import { useMetadataClient } from '../metadata-admin/useMetadata.js'; +import { t, tFormat } from '../metadata-admin/i18n.js'; +import { + buildTranslationRows, + countUntranslated, + readDeclaredStack, + writeTranslationAt, + type DeclaredStackClient, + type TranslationRow, +} from './translationTable.js'; + +type Dict = Record; + +/** The item's own keys, which the table and the JSON view leave to the form above them. */ +const IDENTITY_KEYS = new Set(['name', 'label', 'locale', 'description', 'overrides']); + +/** The kinds a row can be, by the walk's `source`; a kind with no entry shows its id. */ +const KIND_KEYS = new Set([ + 'object', + 'field', + 'option', + 'section', + 'view', + 'action', + 'globalAction', + 'app', + 'navigation', + 'dashboard', + 'widget', + 'dataset', + 'report', + 'picklist', + 'page', + 'flow', + 'hook', + 'position', + 'permission', + 'book', +]); + +export interface StudioTranslationTableProps { + /** The package whose declarations are listed. */ + packageId: string; + /** The translation item as edited. */ + draft: Dict; + onChange: (next: Dict) => void; + readOnly: boolean; + /** The designer's language, the one this editor's own words read in. */ + locale: string; + /** Test seam: the walk over the package. Defaults to the declared-stack read and `@objectstack/lint`. */ + readEntries?: (onProgress: (done: number, total: number) => void) => Promise; + /** Test seam: the language's served translations, `null` when unknown. Defaults to the SDK's i18n read. */ + readServed?: (bundleLocale: string) => Promise; +} + +type EntriesState = + | { status: 'reading'; done: number; total: number } + | { status: 'ready'; entries: ExpectedEntry[] } + | { status: 'error' }; + +function useDefaultReaders(packageId: string) { + const client = useMetadataClient(); + const adapter = useAdapter() as { getClient?: () => { i18n?: { getTranslations?: (l: string) => Promise } } } | null; + const readEntries = React.useCallback( + async (onProgress: (done: number, total: number) => void) => { + // A `.custom` pair is read with the code package it customizes, whose declarations it translates. + const base = customizationBasePackageId(packageId); + const packageIds = base ? [base, packageId] : [packageId]; + const { stack } = await readDeclaredStack(client as unknown as DeclaredStackClient, packageIds, onProgress); + // The linter stays out of the console's eager closure (the console build + // asserts it): it loads when a translation item is opened, as + // `capabilityLint.ts` loads it on publish. + const { collectExpectedEntries } = await import('@objectstack/lint'); + return collectExpectedEntries(stack); + }, + [client, packageId], + ); + const readServed = React.useCallback( + async (bundleLocale: string): Promise => { + const read = adapter?.getClient?.()?.i18n?.getTranslations; + if (!read) return null; + const answer = (await read(bundleLocale)) as { locale?: unknown; translations?: unknown } | null; + // A fallback language is not this one: what it serves translates nothing here. + if (!answer || answer.locale !== bundleLocale) return null; + const tree = answer.translations; + return tree && typeof tree === 'object' ? (tree as Dict) : null; + }, + [adapter], + ); + return { readEntries, readServed }; +} + +export function StudioTranslationTable({ + packageId, + draft, + onChange, + readOnly, + locale, + readEntries: readEntriesProp, + readServed: readServedProp, +}: StudioTranslationTableProps): React.ReactElement { + const defaults = useDefaultReaders(packageId); + const readEntries = readEntriesProp ?? defaults.readEntries; + const readServed = readServedProp ?? defaults.readServed; + const bundleLocale = typeof draft.locale === 'string' ? draft.locale : ''; + + const [entriesState, setEntriesState] = React.useState({ status: 'reading', done: 0, total: 0 }); + const [readNonce, setReadNonce] = React.useState(0); + React.useEffect(() => { + let cancelled = false; + setEntriesState({ status: 'reading', done: 0, total: 0 }); + readEntries((done, total) => { + if (!cancelled) setEntriesState({ status: 'reading', done, total }); + }).then( + (entries) => { + if (!cancelled) setEntriesState({ status: 'ready', entries }); + }, + () => { + if (!cancelled) setEntriesState({ status: 'error' }); + }, + ); + return () => { + cancelled = true; + }; + }, [readEntries, readNonce]); + + const [served, setServed] = React.useState<{ locale: string; tree: Dict | null; failed: boolean } | null>(null); + React.useEffect(() => { + if (!bundleLocale) return; + let cancelled = false; + readServed(bundleLocale).then( + (tree) => { + if (!cancelled) setServed({ locale: bundleLocale, tree, failed: false }); + }, + () => { + if (!cancelled) setServed({ locale: bundleLocale, tree: null, failed: true }); + }, + ); + return () => { + cancelled = true; + }; + }, [readServed, bundleLocale]); + const servedTree = served && served.locale === bundleLocale ? served.tree : null; + + const rows = React.useMemo( + () => (entriesState.status === 'ready' ? buildTranslationRows(entriesState.entries, draft, servedTree, bundleLocale) : []), + [entriesState, draft, servedTree, bundleLocale], + ); + const { untranslated, total } = countUntranslated(rows); + const [onlyMissing, setOnlyMissing] = React.useState(false); + const shown = onlyMissing ? rows.filter((row) => row.status === 'missing') : rows; + const groups = React.useMemo(() => { + const out = new Map(); + for (const row of shown) { + const list = out.get(row.groupKey); + if (list) list.push(row); + else out.set(row.groupKey, [row]); + } + return [...out.entries()]; + }, [shown]); + + const setValue = React.useCallback( + (path: readonly string[], value: string) => onChange(writeTranslationAt(draft, path, value)), + [draft, onChange], + ); + + return ( +
+
+ {entriesState.status === 'ready' && ( +

+ {tFormat('engine.studio.translationTable.count', locale, { untranslated, total })} +

+ )} + +
+ {served?.failed && served.locale === bundleLocale && ( +

+ {tFormat('engine.studio.translationTable.servedUnread', locale, { locale: bundleLocale })} +

+ )} + + {entriesState.status === 'reading' ? ( +

+ {entriesState.total > 0 + ? tFormat('engine.studio.translationTable.readingCount', locale, { + done: entriesState.done, + total: entriesState.total, + }) + : t('engine.studio.translationTable.reading', locale)} +

+ ) : entriesState.status === 'error' ? ( +
+

+ {t('engine.studio.translationTable.readError', locale)} +

+ +
+ ) : rows.length === 0 ? ( +

+ {t('engine.studio.translationTable.empty', locale)} +

+ ) : shown.length === 0 ? ( +

+ {t('engine.studio.translationTable.noneMissing', locale)} +

+ ) : ( +
+ {groups.map(([groupKey, groupRows]) => ( + + ))} +
+ )} + + +
+ ); +} + +function TranslationGroup({ + groupKey, + rows, + bundleLocale, + locale, + readOnly, + onValue, +}: { + groupKey: string; + rows: TranslationRow[]; + bundleLocale: string; + locale: string; + readOnly: boolean; + onValue: (path: readonly string[], value: string) => void; +}): React.ReactElement { + const [group, item = ''] = groupKey.split('.'); + // The item's own label row names it, as the package declares it. + const named = rows.find((row) => row.path.length === 3 && row.path[2] === 'label')?.sourceText; + const missing = rows.filter((row) => row.status === 'missing').length; + return ( +
+
+ + {t(`engine.translationPreview.category.${group}`, locale)} + + {named || item} + {named && named !== item && {item}} + {missing > 0 && ( + + {tFormat('engine.studio.translationTable.groupMissing', locale, { count: missing })} + + )} +
+ + + + + + + + + {rows.map((row) => ( + + ))} + +
{t('engine.studio.translationTable.col.source', locale)}{tFormat('engine.studio.translationTable.col.translation', locale, { locale: bundleLocale })}
+
+ ); +} + +function TranslationRowView({ + row, + locale, + readOnly, + onValue, +}: { + row: TranslationRow; + locale: string; + readOnly: boolean; + onValue: (path: readonly string[], value: string) => void; +}): React.ReactElement { + const kindKey = KIND_KEYS.has(row.kind) ? `engine.studio.translationTable.kind.${row.kind}` : null; + const statusLabel = t(`engine.studio.translationTable.status.${row.status}`, locale); + return ( + + +
+ + {kindKey ? t(kindKey, locale) : row.kind} + + {row.sourceText} +
+ + {row.path.slice(2).join('.')} + + + +
+
+ {row.status === 'elsewhere' && row.served && ( +

+ {tFormat('engine.studio.translationTable.servedElsewhere', locale, { text: row.served })} +

+ )} + + + ); +} + +/** The item's translation groups as JSON: what the table does not list is edited here. */ +function TranslationJsonView({ + draft, + onChange, + readOnly, + locale, +}: { + draft: Dict; + onChange: (next: Dict) => void; + readOnly: boolean; + locale: string; +}): React.ReactElement { + const groups = React.useMemo(() => { + const out: Dict = {}; + for (const [key, value] of Object.entries(draft)) { + if (!IDENTITY_KEYS.has(key) && !key.startsWith('_')) out[key] = value; + } + return out; + }, [draft]); + const shownJson = JSON.stringify(groups, null, 2); + const [text, setText] = React.useState(shownJson); + const [error, setError] = React.useState(null); + const [editing, setEditing] = React.useState(false); + // Follows the table's edits while the author is not typing here. + React.useEffect(() => { + if (!editing) { + setText(shownJson); + setError(null); + } + }, [shownJson, editing]); + const apply = (next: string) => { + setText(next); + let parsed: unknown; + try { + parsed = JSON.parse(next); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + return; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + setError(t('engine.studio.translationTable.jsonNotObject', locale)); + return; + } + setError(null); + const identity: Dict = {}; + for (const [key, value] of Object.entries(draft)) { + if (IDENTITY_KEYS.has(key) || key.startsWith('_')) identity[key] = value; + } + onChange({ ...identity, ...(parsed as Dict) }); + }; + return ( +
+ + {t('engine.studio.translationTable.advanced', locale)} + +
+

{t('engine.studio.translationTable.advancedHint', locale)}

+