Skip to content

Commit bc658aa

Browse files
committed
Merge branch 'main' into copilot/fix-field-type-display-issue
2 parents f62f8f5 + 2754196 commit bc658aa

File tree

22 files changed

+503
-64
lines changed

22 files changed

+503
-64
lines changed

ROADMAP.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,21 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
836836
- [x] Verified: Console `ObjectView` does NOT override `rowHeight`, `density`, or `singleClickEdit`
837837
- [x] Engine defaults (`compact` rows, `singleClickEdit: true`, browser locale dates) flow through correctly
838838

839+
### P1.15 Convention-based Auto-resolution for Object & Field Label i18n ✅
840+
841+
- [x] `useObjectLabel` hook in `@object-ui/i18n` — convention-based resolver (`objectLabel`, `objectDescription`, `fieldLabel`)
842+
- [x] Dynamic app namespace discovery (no hardcoded `crm.` prefix — scans i18next resources for app namespaces)
843+
- [x] `useSafeFieldLabel` shared wrapper for plugins without I18nProvider
844+
- [x] Wired into Console `ObjectView` (breadcrumb, page title, description, drawer title)
845+
- [x] Wired into `ObjectGrid` column headers (ListColumn, string[], auto-generated paths)
846+
- [x] Wired into `ListView` toolbar field labels (hide fields, group by, sort/filter builder)
847+
- [x] Wired into `NavigationRenderer` via optional `resolveObjectLabel` + `t()` props for full i18n
848+
- [x] Wired into Console `AppSidebar` to pass resolver and `t` to NavigationRenderer
849+
- [x] Wired into all form variants (ObjectForm, ModalForm, WizardForm, DrawerForm, TabbedForm, SplitForm)
850+
- [x] `I18nProvider` loads app-specific translations on mount (fixes initial language loading)
851+
- [x] 10 unit tests for `useObjectLabel` hook
852+
- [x] Zero changes to object metadata files or translation files
853+
839854
---
840855

841856
## 🧩 P2 — Polish & Advanced Features

apps/console/src/__tests__/AppSidebar.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ vi.mock('@object-ui/i18n', () => ({
9898
direction: 'ltr',
9999
i18n: {},
100100
}),
101+
useObjectLabel: () => ({
102+
objectLabel: (obj: any) => obj.label,
103+
objectDescription: (obj: any) => obj.description,
104+
fieldLabel: (_objectName: string, _fieldName: string, fallback: string) => fallback,
105+
}),
106+
useSafeFieldLabel: () => ({
107+
fieldLabel: (_objectName: string, _fieldName: string, fallback: string) => fallback,
108+
}),
101109
}));
102110

103111
// Mock @object-ui/components to keep most components but simplify some

apps/console/src/__tests__/BrowserSimulation.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom';
55
import '@object-ui/fields'; // Ensure fields are registered for ObjectForm tests
66
import '@object-ui/plugin-dashboard'; // Ensure dashboard component is registered
77
import '@object-ui/plugin-report'; // Ensure report component is registered
8+
import '@object-ui/plugin-markdown'; // Ensure markdown component is registered
89

910
// -----------------------------------------------------------------------------
1011
// SYSTEM INTEGRATION TEST: Console Application

apps/console/src/__tests__/app-creation-integration.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,14 @@ vi.mock('@object-ui/i18n', () => ({
268268
language: 'en',
269269
direction: 'ltr',
270270
}),
271+
useObjectLabel: () => ({
272+
objectLabel: (obj: any) => obj.label,
273+
objectDescription: (obj: any) => obj.description,
274+
fieldLabel: (_objectName: string, _fieldName: string, fallback: string) => fallback,
275+
}),
276+
useSafeFieldLabel: () => ({
277+
fieldLabel: (_objectName: string, _fieldName: string, fallback: string) => fallback,
278+
}),
271279
}));
272280

273281
describe('Console App Creation Integration', () => {

apps/console/src/components/AppSidebar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ import { useRecentItems } from '../hooks/useRecentItems';
6060
import { useFavorites } from '../hooks/useFavorites';
6161
import { useNavPins } from '../hooks/useNavPins';
6262
import { resolveI18nLabel } from '../utils';
63-
import { useObjectTranslation } from '@object-ui/i18n';
63+
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
6464

6565
// ---------------------------------------------------------------------------
6666
// useNavOrder – localStorage-persisted drag-and-drop reorder for nav items
@@ -157,6 +157,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
157157
const { user, signOut } = useAuth();
158158
const navigate = useNavigate();
159159
const { t } = useObjectTranslation();
160+
const { objectLabel: resolveNavObjectLabel } = useObjectLabel();
160161

161162
// Swipe-from-left-edge gesture to open sidebar on mobile
162163
React.useEffect(() => {
@@ -414,6 +415,8 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
414415
onPinToggle={togglePin}
415416
enableReorder
416417
onReorder={handleReorder}
418+
resolveObjectLabel={(objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback })}
419+
t={t}
417420
/>
418421

419422
{/* Recent Items (elevated position for quick access) */}

apps/console/src/components/ObjectView.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type { ListViewSchema, ViewNavigationConfig, FeedItem } from '@object-ui/
2626
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
2727
import { ViewConfigPanel } from './ViewConfigPanel';
2828
import { useObjectActions } from '../hooks/useObjectActions';
29-
import { useObjectTranslation } from '@object-ui/i18n';
29+
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
3030
import { usePermissions } from '@object-ui/permissions';
3131
import { useAuth } from '@object-ui/auth';
3232
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';
@@ -251,6 +251,7 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
251251
const [searchParams, setSearchParams] = useSearchParams();
252252
const { showDebug, toggleDebug } = useMetadataInspector();
253253
const { t } = useObjectTranslation();
254+
const { objectLabel, objectDescription: objectDesc } = useObjectLabel();
254255

255256
// Inline view config panel state (Airtable-style right sidebar)
256257
const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
@@ -698,13 +699,13 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
698699
<div className="min-w-0">
699700
{/* Breadcrumb: Object > View */}
700701
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-0.5">
701-
<span className="truncate">{objectDef.label}</span>
702+
<span className="truncate">{objectLabel(objectDef)}</span>
702703
<ChevronRight className="h-3 w-3 shrink-0" />
703704
<span className="truncate font-medium text-foreground">{activeView?.label || t('console.objectView.allRecords')}</span>
704705
</div>
705-
<h1 className="text-base sm:text-lg font-semibold tracking-tight text-foreground truncate">{objectDef.label}</h1>
706+
<h1 className="text-base sm:text-lg font-semibold tracking-tight text-foreground truncate">{objectLabel(objectDef)}</h1>
706707
{objectDef.description && (
707-
<p className="text-xs text-muted-foreground truncate hidden sm:block max-w-md">{objectDef.description}</p>
708+
<p className="text-xs text-muted-foreground truncate hidden sm:block max-w-md">{objectDesc(objectDef)}</p>
708709
)}
709710
</div>
710711
</div>
@@ -819,7 +820,7 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
819820
<NavigationOverlay
820821
{...navOverlay}
821822
setIsOpen={(open: boolean) => { if (!open) handleDrawerClose(); }}
822-
title={objectDef.label}
823+
title={objectLabel(objectDef)}
823824
mainContent={
824825
<div className="flex-1 min-w-0 relative h-full flex flex-col">
825826
<div className="flex-1 relative overflow-auto p-3 sm:p-4">

examples/crm/src/pages/help.page.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,41 @@ export const HelpPage = {
77
name: 'main',
88
components: [
99
{
10-
type: 'container',
10+
type: 'markdown',
1111
properties: {
12-
className: 'prose max-w-3xl mx-auto p-8 text-foreground',
13-
children: [
14-
{ type: 'text', properties: { value: '# CRM Help Guide', className: 'text-3xl font-bold mb-6 block' } },
15-
{ type: 'text', properties: { value: 'Welcome to the CRM application. This guide covers the key features available in your sales workspace.', className: 'text-muted-foreground mb-6 block' } },
16-
{ type: 'text', properties: { value: '## Navigation', className: 'text-xl font-semibold mb-3 block' } },
17-
{ type: 'text', properties: { value: '- **Dashboard** — KPI metrics, revenue trends, pipeline charts\n- **Contacts** — Customer and lead management with map view\n- **Accounts** — Company records with geographic map\n- **Opportunities** — Sales pipeline with Kanban board\n- **Projects** — Task tracking with Gantt and Timeline views\n- **Calendar** — Events and meetings\n- **Orders & Products** — Sales catalog and order processing', className: 'whitespace-pre-line mb-6 block' } },
18-
{ type: 'text', properties: { value: '## View Types', className: 'text-xl font-semibold mb-3 block' } },
19-
{ type: 'text', properties: { value: 'Each object supports multiple view types. Use the view switcher in the toolbar to change between:\n- **Grid** — Tabular data with sorting and filtering\n- **Kanban** — Drag-and-drop board (Opportunities → Pipeline)\n- **Calendar** — Date-based event view (Events → Calendar)\n- **Gantt** — Project timeline (Projects → Gantt View)\n- **Map** — Geographic visualization (Accounts → Map View)\n- **Gallery** — Visual cards (Products → Product Gallery)', className: 'whitespace-pre-line mb-6 block' } },
20-
{ type: 'text', properties: { value: '## Keyboard Shortcuts', className: 'text-xl font-semibold mb-3 block' } },
21-
{ type: 'text', properties: { value: '- **⌘+K** — Open Command Palette for quick navigation\n- **⌘+N** — Create new record\n- Click any record row to open the detail panel', className: 'whitespace-pre-line block' } },
22-
]
12+
className: 'max-w-3xl mx-auto p-8',
13+
content: [
14+
'# CRM Help Guide',
15+
'',
16+
'Welcome to the CRM application. This guide covers the key features available in your sales workspace.',
17+
'',
18+
'## Navigation',
19+
'',
20+
'- **Dashboard** — KPI metrics, revenue trends, pipeline charts',
21+
'- **Contacts** — Customer and lead management with map view',
22+
'- **Accounts** — Company records with geographic map',
23+
'- **Opportunities** — Sales pipeline with Kanban board',
24+
'- **Projects** — Task tracking with Gantt and Timeline views',
25+
'- **Calendar** — Events and meetings',
26+
'- **Orders & Products** — Sales catalog and order processing',
27+
'',
28+
'## View Types',
29+
'',
30+
'Each object supports multiple view types. Use the view switcher in the toolbar to change between:',
31+
'',
32+
'- **Grid** — Tabular data with sorting and filtering',
33+
'- **Kanban** — Drag-and-drop board (Opportunities → Pipeline)',
34+
'- **Calendar** — Date-based event view (Events → Calendar)',
35+
'- **Gantt** — Project timeline (Projects → Gantt View)',
36+
'- **Map** — Geographic visualization (Accounts → Map View)',
37+
'- **Gallery** — Visual cards (Products → Product Gallery)',
38+
'',
39+
'## Keyboard Shortcuts',
40+
'',
41+
'- **⌘+K** — Open Command Palette for quick navigation',
42+
'- **⌘+N** — Create new record',
43+
'- Click any record row to open the detail panel',
44+
].join('\n'),
2345
}
2446
}
2547
]

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@
1616
"docs"
1717
],
1818
"scripts": {
19-
"dev": "pnpm dev:msw",
20-
"dev:msw": "pnpm --filter @object-ui/console dev",
19+
"dev": "pnpm os:dev",
2120
"start": "pnpm os:start",
2221
"os:dev": "objectstack dev",
2322
"os:start": "objectstack serve",
23+
"msw:dev": "pnpm --filter @object-ui/console dev",
2424
"build": "turbo run build --filter=!@object-ui/site",
2525
"build:all": "turbo run build",
2626
"test": "vitest run",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { renderHook } from '@testing-library/react';
3+
import React from 'react';
4+
import { I18nProvider } from '../provider';
5+
import { createI18n } from '../i18n';
6+
import { useObjectLabel } from '../useObjectLabel';
7+
8+
/**
9+
* Create a wrapper with custom translations to simulate CRM locale bundles.
10+
*/
11+
function createWrapper(lang: string, translations?: Record<string, unknown>) {
12+
const instance = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
13+
if (translations) {
14+
instance.addResourceBundle(lang, 'translation', translations, true, true);
15+
}
16+
return ({ children }: { children: React.ReactNode }) =>
17+
React.createElement(I18nProvider, { instance }, children);
18+
}
19+
20+
describe('useObjectLabel', () => {
21+
describe('objectLabel', () => {
22+
it('returns the plain label when no translation exists', () => {
23+
const wrapper = createWrapper('en');
24+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
25+
26+
const label = result.current.objectLabel({ name: 'order_item', label: 'Order Item' });
27+
expect(label).toBe('Order Item');
28+
});
29+
30+
it('returns the translated label when a translation exists', () => {
31+
const wrapper = createWrapper('zh', {
32+
crm: { objects: { order_item: { label: '订单明细' } } },
33+
});
34+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
35+
36+
const label = result.current.objectLabel({ name: 'order_item', label: 'Order Item' });
37+
expect(label).toBe('订单明细');
38+
});
39+
40+
it('falls back to plain label when translation key returns empty string', () => {
41+
const wrapper = createWrapper('zh', {
42+
crm: { objects: { order_item: { label: '' } } },
43+
});
44+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
45+
46+
const label = result.current.objectLabel({ name: 'order_item', label: 'Order Item' });
47+
expect(label).toBe('Order Item');
48+
});
49+
});
50+
51+
describe('objectDescription', () => {
52+
it('returns undefined when objectDef has no description', () => {
53+
const wrapper = createWrapper('en');
54+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
55+
56+
const desc = result.current.objectDescription({ name: 'order_item' });
57+
expect(desc).toBeUndefined();
58+
});
59+
60+
it('returns the plain description when no translation exists', () => {
61+
const wrapper = createWrapper('en');
62+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
63+
64+
const desc = result.current.objectDescription({ name: 'order_item', description: 'Line items in an order' });
65+
expect(desc).toBe('Line items in an order');
66+
});
67+
68+
it('returns the translated description when a translation exists', () => {
69+
const wrapper = createWrapper('zh', {
70+
crm: { objects: { order_item: { description: '订单中的商品条目' } } },
71+
});
72+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
73+
74+
const desc = result.current.objectDescription({ name: 'order_item', description: 'Line items in an order' });
75+
expect(desc).toBe('订单中的商品条目');
76+
});
77+
});
78+
79+
describe('fieldLabel', () => {
80+
it('returns the fallback label when no translation exists', () => {
81+
const wrapper = createWrapper('en');
82+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
83+
84+
const label = result.current.fieldLabel('order_item', 'name', 'Line Item');
85+
expect(label).toBe('Line Item');
86+
});
87+
88+
it('returns the translated label when a translation exists', () => {
89+
const wrapper = createWrapper('zh', {
90+
crm: { fields: { order_item: { name: '订单项' } } },
91+
});
92+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
93+
94+
const label = result.current.fieldLabel('order_item', 'name', 'Line Item');
95+
expect(label).toBe('订单项');
96+
});
97+
98+
it('falls back to plain label when translation key returns empty string', () => {
99+
const wrapper = createWrapper('zh', {
100+
crm: { fields: { order_item: { name: '' } } },
101+
});
102+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
103+
104+
const label = result.current.fieldLabel('order_item', 'name', 'Line Item');
105+
expect(label).toBe('Line Item');
106+
});
107+
});
108+
109+
describe('multiple objects', () => {
110+
it('resolves labels for different objects independently', () => {
111+
const wrapper = createWrapper('zh', {
112+
crm: {
113+
objects: {
114+
contact: { label: '联系人' },
115+
opportunity: { label: '商机' },
116+
},
117+
fields: {
118+
contact: { email: '邮箱' },
119+
opportunity: { stage: '阶段' },
120+
},
121+
},
122+
});
123+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
124+
125+
expect(result.current.objectLabel({ name: 'contact', label: 'Contact' })).toBe('联系人');
126+
expect(result.current.objectLabel({ name: 'opportunity', label: 'Opportunity' })).toBe('商机');
127+
expect(result.current.fieldLabel('contact', 'email', 'Email')).toBe('邮箱');
128+
expect(result.current.fieldLabel('opportunity', 'stage', 'Stage')).toBe('阶段');
129+
});
130+
});
131+
});

packages/i18n/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export { createI18n, getDirection, getAvailableLanguages, type I18nConfig, type
3737
// React integration
3838
export { I18nProvider, useObjectTranslation, useI18nContext, type I18nProviderProps } from './provider';
3939

40+
// Convention-based object/field label i18n
41+
export { useObjectLabel, useSafeFieldLabel } from './useObjectLabel';
42+
4043
// Locale packs
4144
export { builtInLocales, isRTL, RTL_LANGUAGES } from './locales/index';
4245
export { default as en } from './locales/en';

0 commit comments

Comments
 (0)