diff --git a/.changeset/7142-activity-timeline-empty-i18n.md b/.changeset/7142-activity-timeline-empty-i18n.md new file mode 100644 index 000000000..268d7ec35 --- /dev/null +++ b/.changeset/7142-activity-timeline-empty-i18n.md @@ -0,0 +1,23 @@ +--- +'@object-ui/plugin-detail': patch +--- + +`ActivityTimeline`'s empty state speaks the session locale (objectui#7142). + +The title was a raw English JSX literal — `title="No activity recorded"`, not a +`t()` call and not an inline `defaultValue` — so it never reached the pack +system and stayed English in all ten locales. Measured before the fix by +rendering `activities={[]}` under a zh `I18nProvider`: the card read +`"Activity(0)No activity recorded"`, while its sibling `RecordActivityTimeline` +rendered `"活动(0)全部动态暂无活动记录"` from the same packs. + +The call site now reads `detail.noActivity`, the key the sibling already uses. +Reusing it rather than minting a second key is a measured decision: the `en` +pack value for that key is `'No activity recorded'`, byte-identical to the +literal it replaces, so both surfaces were already saying the same words in +English and a new key would have forked one sentence across ten packs for no +copy difference. No pack was edited — the key is already translated in all ten, +verified by reading `detail.noActivity` out of each pack object. + +Both routes to the box are covered: an empty `activities` array, and a +populated timeline filtered down to a type with no entries. diff --git a/packages/plugin-detail/src/ActivityTimeline.i18n.test.tsx b/packages/plugin-detail/src/ActivityTimeline.i18n.test.tsx new file mode 100644 index 000000000..e9fcf84c8 --- /dev/null +++ b/packages/plugin-detail/src/ActivityTimeline.i18n.test.tsx @@ -0,0 +1,122 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ActivityTimeline`'s empty state speaks the session locale — objectui#7142. + * + * The title was the raw English literal `"No activity recorded"`, written + * straight into the JSX. It was not a `t()` call and not an inline + * `defaultValue`, so it never reached the pack system at all and a zh session + * read English. Measured before the fix by rendering `activities={[]}` under a + * zh `I18nProvider`: the card came out `"Activity(0)No activity recorded"`, + * while its sibling `RecordActivityTimeline` rendered `"活动(0)全部动态暂无活动记录"` + * from the same packs. + * + * The call site now reads `detail.noActivity` — the key the sibling already + * uses (`RecordActivityTimeline.tsx`), reused rather than forked. That reuse is + * a measured decision, not an assumption: the `en` pack value for the key is + * `'No activity recorded'`, **byte-identical** to the literal being replaced, + * so both surfaces were already saying the same words in English and a second + * key would have forked one sentence across ten packs for no copy difference. + * + * `zh` and `ar` are the load-bearing assertions. An `en`-only test would have + * been green *before* the fix too — the literal and the `en` pack value are the + * same string — so English proves nothing here. Non-Latin locales are what + * discriminate the pack lookup from the hardcoded literal. + * + * The provider-less case is asserted alongside them because + * `useDetailTranslation` is a `createSafeTranslation` hook: a host with no + * `I18nProvider` must get the English default from + * `DETAIL_DEFAULT_TRANSLATIONS`, never the raw key `detail.noActivity` in the + * empty box. No inline `defaultValue` is used anywhere here (objectui#3517) — + * the key resolves from the packs or from that defaults map. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import type { ActivityEntry } from '@object-ui/types'; +import { ActivityTimeline } from './ActivityTimeline'; + +afterEach(() => cleanup()); + +/** The pack values for `detail.noActivity`, read from the packs themselves. */ +const EN = 'No activity recorded'; +const ZH = '暂无活动记录'; +const AR = 'لا يوجد نشاط مسجل'; +const RAW_KEY = 'detail.noActivity'; + +function renderEmptyIn(language: string) { + return render( + + + , + ); +} + +describe('ActivityTimeline empty state — locale resolution (objectui#7142)', () => { + it('reads the zh pack value under a zh session', () => { + renderEmptyIn('zh'); + + expect(screen.getByText(ZH)).toBeTruthy(); + // The regression itself: English must not survive into a zh session. + expect(screen.queryByText(EN)).toBeNull(); + }); + + it('reads the ar pack value under an ar session', () => { + renderEmptyIn('ar'); + + expect(screen.getByText(AR)).toBeTruthy(); + expect(screen.queryByText(EN)).toBeNull(); + }); + + it('still reads English under an en session', () => { + renderEmptyIn('en'); + + expect(screen.getByText(EN)).toBeTruthy(); + }); + + it('falls back to the English default with no provider mounted', () => { + render(); + + expect(screen.getByText(EN)).toBeTruthy(); + }); + + it('never renders the raw key, provider or not', () => { + renderEmptyIn('zh'); + expect(screen.queryByText(RAW_KEY)).toBeNull(); + cleanup(); + + render(); + expect(screen.queryByText(RAW_KEY)).toBeNull(); + }); + + it('translates the empty state reached by filtering, not just by an empty list', () => { + // The other route to this box: a non-empty timeline filtered down to a type + // that has no entries. Same call site, but it is the only one a host reaches + // with `activities` actually populated. + const activities: ActivityEntry[] = [ + { + id: 'a1', + type: 'comment', + user: 'Ada', + timestamp: '2026-01-02T00:00:00.000Z', + } as ActivityEntry, + ]; + + render( + + + , + ); + + expect(screen.getByText(ZH)).toBeTruthy(); + expect(screen.queryByText(EN)).toBeNull(); + }); +}); diff --git a/packages/plugin-detail/src/ActivityTimeline.tsx b/packages/plugin-detail/src/ActivityTimeline.tsx index 94ece81b6..6bb6ce321 100644 --- a/packages/plugin-detail/src/ActivityTimeline.tsx +++ b/packages/plugin-detail/src/ActivityTimeline.tsx @@ -10,6 +10,7 @@ import * as React from 'react'; import { cn, Card, CardHeader, CardTitle, CardContent, DataEmptyState } from '@object-ui/components'; import { Activity, Edit, PlusCircle, Trash2, MessageSquare, ArrowRightLeft, Filter } from 'lucide-react'; import type { ActivityEntry } from '@object-ui/types'; +import { useDetailTranslation } from './useDetailTranslation'; export type ActivityFilterType = ActivityEntry['type'] | 'all'; @@ -92,6 +93,7 @@ export const ActivityTimeline: React.FC = ({ defaultFilter = 'all', className, }) => { + const { t } = useDetailTranslation(); const [activeFilter, setActiveFilter] = React.useState(defaultFilter); const filteredActivities = React.useMemo(() => { @@ -135,7 +137,7 @@ export const ActivityTimeline: React.FC = ({ )} {filteredActivities.length === 0 ? ( - + ) : (
{/* Timeline line */}