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
17 changes: 17 additions & 0 deletions .changeset/6165-home-item-type-label-parity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

Home renders one agreed label for an item kind that has no translation key.

The rail (`HomeContinue`), `RecentApps` and `StarredApps` all resolve the same
`home.recentApps.itemType.*` label, and each spelled the lookup itself. They had
drifted: the rail fell back to the bare kind (`report`) where both card surfaces
fell back to the capitalised one (`Report`) — two spellings of the same word on
one screen. All three now resolve through a single `recentItemTypeLabel` helper,
so the fallback cannot drift apart again.

User-visible: the rail's label for an unkeyed kind changes from `report` to
`Report`. Every kind shipping today carries a key, so no label changes for them;
this is about the next kind added, and any host passing a kind the locales do
not carry.
3 changes: 2 additions & 1 deletion packages/app-shell/src/console/home/HomeRail.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
import type { ActivityItem } from '../../layout/ActivityFeed.js';
import type { HomeInboxStatus, HomeNotification } from '../../hooks/useHomeInbox.js';
import type { RecentItem } from '../../hooks/useRecentItems.js';
import { recentItemTypeLabel } from './recentItemTypeLabel.js';
import { timeAgo } from '../../utils/relativeTime.js';

type TFn = (key: string, opts?: any) => string;
Expand DownExpand Up@@ -248,7 +249,7 @@ export function HomeContinue({ items, onOpen, t }: { items: RecentItem[]; onOpen
icon={RECENT_ICON[it.type] || FileText}
iconClass={RECENT_TONE[it.type] || 'bg-muted text-muted-foreground'}
label={it.label}
meta={t(`home.recentApps.itemType.${it.type}`, { defaultValue: it.type })}
meta={recentItemTypeLabel(t, it.type)}
onClick={() => onOpen(it.href)}
/>
))}
Expand Down
6 changes: 2 additions & 4 deletions packages/app-shell/src/console/home/RecentApps.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { useNavigate } from 'react-router-dom';
import { useObjectTranslation } from '@object-ui/i18n';
import { Card, CardContent, cn } from '@object-ui/components';
import { Clock, ArrowUpRight, Database, FileText, LayoutDashboard, File } from 'lucide-react';
import { capitalizeFirst } from '../../utils/index.js';
import { recentItemTypeLabel } from './recentItemTypeLabel.js';
import type { RecentItem } from '../../hooks/useRecentItems.js';

interface RecentAppsProps {
Expand DownExpand Up@@ -52,9 +52,7 @@ export function RecentApps({ items }: RecentAppsProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{items.map((item) => {
const Icon = TYPE_ICONS[item.type] || Database;
const typeLabel = t(`home.recentApps.itemType.${item.type}`, {
defaultValue: capitalizeFirst(item.type),
});
const typeLabel = recentItemTypeLabel(t, item.type);
const tone = TYPE_TONES[item.type] || TYPE_TONES.object;
return (
<Card
Expand Down
10 changes: 4 additions & 6 deletions packages/app-shell/src/console/home/StarredApps.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { useNavigate } from 'react-router-dom';
import { useObjectTranslation } from '@object-ui/i18n';
import { Card, CardContent, cn } from '@object-ui/components';
import { Star, ArrowUpRight, Database, FileText, LayoutDashboard, File } from 'lucide-react';
import { capitalizeFirst } from '../../utils/index.js';
import { recentItemTypeLabel } from './recentItemTypeLabel.js';
import type { FavoriteItem } from '../../hooks/useFavorites.js';

interface StarredAppsProps {
Expand DownExpand Up@@ -56,11 +56,9 @@ export function StarredApps({ items }: StarredAppsProps) {
const tone = TYPE_TONES[item.type] || TYPE_TONES.object;
// Reuse the recentApps.itemType.* keys so Starred and Recently
// Accessed surface the same localized labels (e.g. "记录" vs
// "Record"). Falls back to the capitalized english type so
// unknown types still render readably.
const typeLabel = t(`home.recentApps.itemType.${item.type}`, {
defaultValue: capitalizeFirst(item.type),
});
// "Record"). Resolved through the shared helper so all three Home
// surfaces fall back identically (objectui#6165).
const typeLabel = recentItemTypeLabel(t, item.type);
return (
<Card
key={item.id}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Home item-type label parity (objectui#6165).
*
* Three Home surfaces render a label for the same item kind — the rail
* (`HomeRail.HomeContinue`), the Recently-Accessed cards (`RecentApps`) and
* the Starred cards (`StarredApps`) — through one key namespace,
* `home.recentApps.itemType.*`. They disagreed on the FALLBACK: the cards
* rendered `capitalizeFirst(type)` while the rail rendered the bare `type`,
* so an unkeyed kind read `Report` on the cards and `report` in the rail, on
* the same screen.
*
* ⚠️ WHY THE FIXTURE USES A SYNTHETIC KIND. Every member of the union today
* (`object|dashboard|page|report|record|metadata`, plus `nav` on the favorite
* side) HAS a key, so a test built on a real member takes the keyed path and
* never reaches the fallback — it passes both before and after the fix and
* proves nothing. The pin therefore uses a kind with NO key. That is the
* whole point of the card: the defect is dormant for today's members and
* wakes up for the next one added.
*
* ⚠️ WHAT IS ASSERTED. The defect is DISAGREEMENT BETWEEN CONSUMERS, so the
* assertion is agreement — all three surfaces resolving one kind to one
* string — not any single component's output in isolation.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen, within } from '@testing-library/react';

import type { RecentItem } from '../../../hooks/useRecentItems.js';
import type { FavoriteItem } from '../../../hooks/useFavorites.js';
import { capitalizeFirst } from '../../../utils/index.js';

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));

/**
* One translator for all three surfaces.
*
* The keyed entry is spelled in zh (verbatim from
* `packages/i18n/src/locales/zh.ts`) on purpose: `capitalizeFirst` could
* never produce `报表`, so the keyed control below cannot pass by accidentally
* running the fallback. Anything not in this map misses, exactly as i18next
* misses an absent key, and the call site's `defaultValue` decides.
*/
vi.mock('@object-ui/i18n', async (importOriginal) => {
const KEYED: Record<string, string> = {
'home.recentApps.itemType.report': '报表',
};
return {
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) =>
KEYED[key] ?? String(options?.defaultValue ?? key),
language: 'zh',
}),
};
});

import { useObjectTranslation } from '@object-ui/i18n';
import { HomeContinue } from '../HomeRail.js';
import { RecentApps } from '../RecentApps.js';
import { StarredApps } from '../StarredApps.js';

/** A kind with NO `home.recentApps.itemType.*` key. */
const UNKEYED_KIND = 'playbook';
/** A kind WITH a key — the control that must stay untouched. */
const KEYED_KIND = 'report';

const ITEM_LABEL = 'Quarterly revenue';

/**
* The rail takes `t` as a prop while the cards pull it from the hook. Reading
* it from the same hook here is what makes this a parity test rather than two
* unrelated renders: all three surfaces are driven by one translator.
*/
function Rail({ items }: { items: RecentItem[] }) {
const { t } = useObjectTranslation();
return <HomeContinue items={items} onOpen={() => {}} t={t} />;
}

/**
* Every surface renders the item label followed by the type label inside the
* item node, so the type label is what remains once the known label is
* removed. The two `expect`s are instrument checks — they fail loudly if a
* surface stops rendering in that shape, instead of silently returning `''`
* and making a comparison of two empty strings look like agreement.
*/
function typeLabelOf(node: HTMLElement, itemLabel: string): string {
const text = (node.textContent ?? '').trim();
expect(text.startsWith(itemLabel)).toBe(true);
const typeLabel = text.slice(itemLabel.length).trim();
expect(typeLabel).not.toBe('');
return typeLabel;
}

function renderAllThree(kind: string) {
const recent: RecentItem[] = [
{
id: 'r1',
label: ITEM_LABEL,
href: '/x',
type: kind as RecentItem['type'],
visitedAt: new Date().toISOString(),
},
];
const favorites: FavoriteItem[] = [
{
id: 'f1',
label: ITEM_LABEL,
href: '/x',
type: kind as FavoriteItem['type'],
favoritedAt: new Date().toISOString(),
},
];

render(
<>
<div data-testid="rail-surface">
<Rail items={recent} />
</div>
<RecentApps items={recent} />
<StarredApps items={favorites} />
</>,
);

return {
rail: typeLabelOf(within(screen.getByTestId('rail-surface')).getByRole('button'), ITEM_LABEL),
recentCard: typeLabelOf(screen.getByTestId('recent-item-r1'), ITEM_LABEL),
starredCard: typeLabelOf(screen.getByTestId('starred-item-f1'), ITEM_LABEL),
};
}

describe('Home item-type label parity across the three consumers', () => {
it('fixture guard: the two candidate spellings differ for the unkeyed kind', () => {
// Without this, a single-character or already-capitalized kind would make
// both spellings identical and the pin below would agree trivially —
// passing while proving nothing.
expect(UNKEYED_KIND.length).toBeGreaterThan(1);
expect(capitalizeFirst(UNKEYED_KIND)).not.toBe(UNKEYED_KIND);
});

it('THE PIN: a kind with no translation key renders one identical label on all three surfaces', () => {
const { rail, recentCard, starredCard } = renderAllThree(UNKEYED_KIND);

// Agreement is the assertion. Before objectui#6165 the rail answered
// 'playbook' here while both cards answered 'Playbook'.
expect(rail).toBe(recentCard);
expect(rail).toBe(starredCard);

// And the agreed spelling is the readable one the two cards already used
// — pinning the DIRECTION of the convergence, so a future "make them all
// agree" that converges on the bare lowercase fails here too.
expect(rail).toBe(capitalizeFirst(UNKEYED_KIND));
});

it('control: a kind WITH a translation key still resolves through the key on all three surfaces', () => {
// Passes before and after the fix by design: the change is bounded to the
// fallback, and this is what proves it. `报表` is unreachable from the
// fallback, so a green here means the keyed path genuinely ran.
const { rail, recentCard, starredCard } = renderAllThree(KEYED_KIND);

expect(rail).toBe('报表');
expect(recentCard).toBe('报表');
expect(starredCard).toBe('报表');
});
});
42 changes: 42 additions & 0 deletions packages/app-shell/src/console/home/recentItemTypeLabel.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* recentItemTypeLabel
*
* The ONE place the `home.recentApps.itemType.*` label is resolved.
*
* Three Home surfaces render a label for the same item kind — the rail
* (`HomeRail.HomeContinue`), the Recently-Accessed cards (`RecentApps`) and
* the Starred cards (`StarredApps`). Each used to spell the lookup itself,
* and they drifted: the two card surfaces fell back to
* `capitalizeFirst(type)` while the rail fell back to the bare `type`, so any
* kind without a translation key rendered as `Report` on the cards and
* `report` in the rail — on the same screen (objectui#6165).
*
* The duplication is what allowed the drift, so the fix removes the
* duplication rather than only re-spelling the odd one out. A fourth surface
* gets the agreed behaviour by construction.
*
* ⚠️ `type` is deliberately `string`, not a union. The three call sites do
* NOT share one union: the rail and `RecentApps` take `RecentItem['type']`
* (`… | 'metadata'`) while `StarredApps` takes `FavoriteItem['type']`
* (`… | 'nav'`). They share the KEY NAMESPACE, not the type — narrowing this
* parameter to either union would reject a legitimate caller.
*
* @module
*/

import { capitalizeFirst } from '../../utils/index.js';

type TFn = (key: string, opts?: any) => string;

/**
* Resolve the display label for a Home item kind.
*
* Falls back to the capitalized kind (`report` -> `Report`) when the locale
* carries no `home.recentApps.itemType.<type>` key, matching what the card
* surfaces have always rendered.
*/
export function recentItemTypeLabel(t: TFn, type: string): string {
return t(`home.recentApps.itemType.${type}`, { defaultValue: capitalizeFirst(type) });
}
Loading