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
102 changes: 102 additions & 0 deletions packages/i18n/src/__tests__/useObjectLabel-view.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
26 changes: 22 additions & 4 deletions packages/i18n/src/useObjectLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<objectName>.<viewName>`) 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.
Expand Down Expand Up @@ -341,15 +359,15 @@ 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.
* Convention: `{ns}.objects.{objectName}._views.{viewName}.description`.
*/
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;
},

Expand All @@ -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 };
},
Expand Down
Loading