diff --git a/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx b/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx new file mode 100644 index 000000000..49df97b83 --- /dev/null +++ b/packages/i18n/src/__tests__/useObjectLabel-view.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, useObjectTranslation } from '../provider'; +import { useObjectLabel } from '../useObjectLabel'; + +const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + I18nProvider, + { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, + children, + ); + +describe('useObjectLabel().viewLabel', () => { + it('resolves an authored view translation from a qualified runtime view id', () => { + const { result } = renderHook( + () => ({ labels: useObjectLabel(), i18n: useObjectTranslation().i18n }), + { wrapper }, + ); + result.current.i18n.addResourceBundle( + 'en', + 'translation', + { + crm: { + objects: { + crm_opportunity: { + _views: { + pipeline_kanban: { + label: 'Localized pipeline', + description: 'Localized pipeline description', + emptyState: { + title: 'No localized records', + message: 'Create a localized record to begin.', + }, + }, + }, + }, + }, + }, + }, + true, + true, + ); + + expect( + result.current.labels.viewLabel( + 'crm_opportunity', + 'crm_opportunity.pipeline_kanban', + 'Sales Pipeline', + ), + ).toBe('Localized pipeline'); + expect( + result.current.labels.viewDescription( + 'crm_opportunity', + 'crm_opportunity.pipeline_kanban', + 'Manage opportunities by stage', + ), + ).toBe('Localized pipeline description'); + expect( + result.current.labels.viewEmptyState( + 'crm_opportunity', + 'crm_opportunity.pipeline_kanban', + { title: 'No opportunities', message: 'Create one to begin.' }, + ), + ).toEqual({ + title: 'No localized records', + message: 'Create a localized record to begin.', + }); + }); + + it('continues to resolve an unqualified authored view name', () => { + const { result } = renderHook( + () => ({ labels: useObjectLabel(), i18n: useObjectTranslation().i18n }), + { wrapper }, + ); + result.current.i18n.addResourceBundle( + 'en', + 'translation', + { + crm: { + objects: { + crm_opportunity: { + _views: { + pipeline_kanban: { label: 'Localized pipeline' }, + }, + }, + }, + }, + }, + true, + true, + ); + + expect( + result.current.labels.viewLabel( + 'crm_opportunity', + 'pipeline_kanban', + 'Sales Pipeline', + ), + ).toBe('Localized pipeline'); + }); +}); diff --git a/packages/i18n/src/useObjectLabel.ts b/packages/i18n/src/useObjectLabel.ts index 002ffd3d3..276293bc4 100644 --- a/packages/i18n/src/useObjectLabel.ts +++ b/packages/i18n/src/useObjectLabel.ts @@ -202,6 +202,24 @@ export function useObjectLabel() { : [`reports.${reportName}.${tail}`]; }; + /** + * Build suffix candidates for a list-view scoped key. + * + * The runtime uses qualified view ids (`.`) in URLs + * and metadata records, while translation bundles are keyed by the authored + * view name under `_views`. Resolve the unqualified name first so every + * surface can pass its canonical id without leaking the metadata fallback. + */ + const viewSuffixes = (objectName: string, viewName: string, tail: string): string[] => { + const objectNames = [objectName, stripNamespace(objectName)]; + const matchedPrefix = objectNames + .map((name) => `${name}.`) + .find((prefix) => viewName.startsWith(prefix)); + const shortViewName = matchedPrefix ? viewName.slice(matchedPrefix.length) : viewName; + const viewNames = shortViewName === viewName ? [viewName] : [shortViewName, viewName]; + return viewNames.flatMap((name) => objectSuffixes(objectName, `_views.${name}.${tail}`)); + }; + return { /** * Resolve translated object label, falling back to objectDef.label. @@ -341,7 +359,7 @@ export function useObjectLabel() { * Convention (per @objectstack/spec): `{ns}.objects.{objectName}._views.{viewName}.label`. */ viewLabel: (objectName: string, viewName: string, fallback: string) => - resolve(objectSuffixes(objectName, `_views.${viewName}.label`), fallback), + resolve(viewSuffixes(objectName, viewName, 'label'), fallback), /** * Resolve translated list-view description. @@ -349,7 +367,7 @@ export function useObjectLabel() { */ viewDescription: (objectName: string, viewName: string, fallback?: string) => { const fb = fallback ?? ''; - const resolved = resolve(objectSuffixes(objectName, `_views.${viewName}.description`), fb); + const resolved = resolve(viewSuffixes(objectName, viewName, 'description'), fb); return resolved || undefined; }, @@ -366,10 +384,10 @@ export function useObjectLabel() { ) => { if (!fallback) return undefined; const title = fallback.title - ? resolve(objectSuffixes(objectName, `_views.${viewName}.emptyState.title`), fallback.title) + ? resolve(viewSuffixes(objectName, viewName, 'emptyState.title'), fallback.title) : fallback.title; const message = fallback.message - ? resolve(objectSuffixes(objectName, `_views.${viewName}.emptyState.message`), fallback.message) + ? resolve(viewSuffixes(objectName, viewName, 'emptyState.message'), fallback.message) : fallback.message; return { ...fallback, title, message }; },