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
10 changes: 8 additions & 2 deletions apps/console/src/__tests__/SpecCompliance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ describe('ObjectStack Spec v0.9.0 Compliance', () => {
expect(app.name).toBeDefined();
expect(typeof app.name).toBe('string');
expect(app.label).toBeDefined();
expect(typeof app.label).toBe('string');
expect(['string', 'object']).toContain(typeof app.label);
if (typeof app.label === 'object') {
expect(app.label).toHaveProperty('key');
}
Comment on lines 27 to +31
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated label type check treats null as an object (typeof null === 'object'). Since toBeDefined() doesn’t exclude null, this test can behave unexpectedly. Consider explicitly asserting app.label/app.description are not null before the object-shape checks (and optionally that key is a string).

Copilot uses AI. Check for mistakes.

// Name convention: lowercase snake_case
expect(app.name).toMatch(/^[a-z][a-z0-9_]*$/);
Expand All @@ -36,7 +39,10 @@ describe('ObjectStack Spec v0.9.0 Compliance', () => {

// Optional fields that should be defined if present
if (app.description) {
expect(typeof app.description).toBe('string');
expect(['string', 'object']).toContain(typeof app.description);
if (typeof app.description === 'object') {
expect(app.description).toHaveProperty('key');
}
}
if (app.version) {
expect(typeof app.version).toBe('string');
Expand Down
4 changes: 2 additions & 2 deletions apps/console/src/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
<div className="flex size-6 items-center justify-center rounded-sm border">
{app.icon ? React.createElement(getIcon(app.icon), { className: "size-3" }) : <Database className="size-3" />}
</div>
{app.label}
{resolveI18nLabel(app.label, t)}
{activeApp.name === app.name && <span className="ml-auto text-xs">✓</span>}
</DropdownMenuItem>
))}
Expand Down Expand Up @@ -530,7 +530,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
return (
<Link key={item.id} to={href} className="flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center">
<NavIcon className="h-5 w-5" />
<span className="text-[10px] truncate max-w-[60px]">{item.label}</span>
<span className="text-[10px] truncate max-w-[60px]">{resolveI18nLabel(item.label, t)}</span>
</Link>
);
})}
Expand Down
21 changes: 11 additions & 10 deletions apps/console/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { useTheme } from './theme-provider';
import { useExpressionContext, evaluateVisibility } from '../context/ExpressionProvider';
import { useObjectTranslation } from '@object-ui/i18n';
import { resolveI18nLabel } from '../utils';

/** Resolve a Lucide icon by name (kebab-case or PascalCase) */
function getIcon(name?: string): React.ElementType {
Expand Down Expand Up @@ -101,11 +102,11 @@ export function CommandPalette({ apps, activeApp, objects: _objects, onAppChange
return (
<CommandItem
key={item.id}
value={`object ${item.label} ${item.objectName}`}
value={`object ${resolveI18nLabel(item.label, t)} ${item.objectName}`}
onSelect={() => runCommand(() => navigate(`${baseUrl}/${item.objectName}`))}
>
<Icon className="mr-2 h-4 w-4" />
<span>{item.label}</span>
<span>{resolveI18nLabel(item.label, t)}</span>
</CommandItem>
);
})}
Expand All @@ -120,11 +121,11 @@ export function CommandPalette({ apps, activeApp, objects: _objects, onAppChange
.map(item => (
<CommandItem
key={item.id}
value={`dashboard ${item.label} ${item.dashboardName}`}
value={`dashboard ${resolveI18nLabel(item.label, t)} ${item.dashboardName}`}
onSelect={() => runCommand(() => navigate(`${baseUrl}/dashboard/${item.dashboardName}`))}
>
<LayoutDashboard className="mr-2 h-4 w-4" />
<span>{item.label}</span>
<span>{resolveI18nLabel(item.label, t)}</span>
</CommandItem>
))}
</CommandGroup>
Expand All @@ -138,11 +139,11 @@ export function CommandPalette({ apps, activeApp, objects: _objects, onAppChange
.map(item => (
<CommandItem
key={item.id}
value={`page ${item.label} ${item.pageName}`}
value={`page ${resolveI18nLabel(item.label, t)} ${item.pageName}`}
onSelect={() => runCommand(() => navigate(`${baseUrl}/page/${item.pageName}`))}
>
<FileText className="mr-2 h-4 w-4" />
<span>{item.label}</span>
<span>{resolveI18nLabel(item.label, t)}</span>
</CommandItem>
))}
</CommandGroup>
Expand All @@ -156,11 +157,11 @@ export function CommandPalette({ apps, activeApp, objects: _objects, onAppChange
.map(item => (
<CommandItem
key={item.id}
value={`report ${item.label} ${item.reportName}`}
value={`report ${resolveI18nLabel(item.label, t)} ${item.reportName}`}
onSelect={() => runCommand(() => navigate(`${baseUrl}/report/${item.reportName}`))}
>
<BarChart3 className="mr-2 h-4 w-4" />
<span>{item.label}</span>
<span>{resolveI18nLabel(item.label, t)}</span>
</CommandItem>
))}
</CommandGroup>
Expand All @@ -178,11 +179,11 @@ export function CommandPalette({ apps, activeApp, objects: _objects, onAppChange
return (
<CommandItem
key={app.name}
value={`app ${app.label} ${app.name}`}
value={`app ${resolveI18nLabel(app.label, t)} ${app.name}`}
onSelect={() => runCommand(() => onAppChange(app.name))}
>
<Icon className="mr-2 h-4 w-4" />
<span>{app.label}</span>
<span>{resolveI18nLabel(app.label, t)}</span>
{app.name === activeApp?.name && (
<span className="ml-auto text-xs text-muted-foreground">{t('console.commandPalette.current')}</span>
)}
Expand Down
5 changes: 3 additions & 2 deletions apps/console/src/components/ConsoleLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AppShell } from '@object-ui/layout';
import { AppSidebar } from './AppSidebar';
import { AppHeader } from './AppHeader';
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar';
import { resolveI18nLabel } from '../utils';
import type { ConnectionState } from '../dataSource';

interface ConsoleLayoutProps {
Expand Down Expand Up @@ -46,7 +47,7 @@ export function ConsoleLayout({
}
navbar={
<AppHeader
appName={activeApp?.label || activeAppName}
appName={resolveI18nLabel(activeApp?.label) || activeAppName}
objects={objects}
connectionState={connectionState}
/>
Expand All @@ -60,7 +61,7 @@ export function ConsoleLayout({
favicon: activeApp.branding.favicon,
logo: activeApp.branding.logo,
title: activeApp.label
? `${activeApp.label} — ObjectStack Console`
? `${resolveI18nLabel(activeApp.label)} — ObjectStack Console`
: undefined,
}
: undefined
Expand Down
5 changes: 3 additions & 2 deletions apps/console/src/pages/system/AppManagementPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import { useMetadata } from '../../context/MetadataProvider';
import { resolveI18nLabel } from '../../utils';

export function AppManagementPage() {
const navigate = useNavigate();
Expand Down Expand Up @@ -208,14 +209,14 @@ export function AppManagementPage() {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{app.label || app.name}</span>
<span className="font-medium truncate">{resolveI18nLabel(app.label) || app.name}</span>
{isDefault && <Badge variant="default" className="text-xs">Default</Badge>}
<Badge variant={isActive ? 'secondary' : 'outline'} className="text-xs">
{isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
{app.description && (
<p className="text-xs text-muted-foreground truncate">{app.description}</p>
<p className="text-xs text-muted-foreground truncate">{resolveI18nLabel(app.description)}</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
Expand Down
22 changes: 15 additions & 7 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import { forwardRef, useState, useEffect, useCallback, useRef } from 'react';
import { RefreshCw } from 'lucide-react';
import { isObjectProvider } from './utils';

/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined {
if (label === undefined || label === null) return undefined;
if (typeof label === 'string') return label;
return label.defaultValue || label.key;
}
Comment on lines +16 to +21
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In resolveLabel, the object shape ({ key?: string; defaultValue?: string }) doesn’t match the repo’s I18nLabel spec (key is required and params may be present). Consider typing this as string | I18nLabel | undefined (import I18nLabel from @object-ui/types) and keeping the resolver aligned with that shape so it won’t drift from the spec over time.

Copilot uses AI. Check for mistakes.

// Color palette for charts
const CHART_COLORS = [
'hsl(var(--chart-1))',
Expand Down Expand Up @@ -248,7 +255,8 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro

const componentSchema = getComponentSchema();
const isSelfContained = widget.type === 'metric';
const widgetKey = widget.id || widget.title || `widget-${index}`;
const resolvedTitle = resolveLabel(widget.title);
const widgetKey = widget.id || resolvedTitle || `widget-${index}`;
const isSelected = designMode && selectedWidgetId === widget.id;

const designModeProps = designMode ? {
Expand All @@ -257,7 +265,7 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
role: 'button' as const,
tabIndex: 0,
'aria-selected': isSelected,
'aria-label': `Widget: ${widget.title || `Widget ${index + 1}`}`,
'aria-label': `Widget: ${resolvedTitle || `Widget ${index + 1}`}`,
onClick: (e: React.MouseEvent) => handleWidgetClick(e, widget.id),
onKeyDown: (e: React.KeyboardEvent) => handleWidgetKeyDown(e, widget.id, index),
} : {};
Expand Down Expand Up @@ -305,10 +313,10 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
}: undefined}
{...designModeProps}
>
{widget.title && (
{resolvedTitle && (
<CardHeader className="pb-2 border-b border-border/40 bg-muted/20 px-3 sm:px-6">
<CardTitle className="text-sm sm:text-base font-medium tracking-tight truncate" title={widget.title}>
{widget.title}
<CardTitle className="text-sm sm:text-base font-medium tracking-tight truncate" title={resolvedTitle}>
{resolvedTitle}
</CardTitle>
</CardHeader>
)}
Expand All @@ -325,10 +333,10 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
const headerSection = schema.header && (
<div className="col-span-full mb-4">
{schema.header.showTitle !== false && schema.title && (
<h2 className="text-lg font-semibold tracking-tight">{schema.title}</h2>
<h2 className="text-lg font-semibold tracking-tight">{resolveLabel(schema.title)}</h2>
)}
{schema.header.showDescription !== false && schema.description && (
<p className="text-sm text-muted-foreground mt-1">{schema.description}</p>
<p className="text-sm text-muted-foreground mt-1">{resolveLabel(schema.description)}</p>
)}
{schema.header.actions && schema.header.actions.length > 0 && (
<div className="flex gap-2 mt-3">
Expand Down