diff --git a/.changeset/30311-served-app-labels.md b/.changeset/30311-served-app-labels.md new file mode 100644 index 00000000000..728d2647c7f --- /dev/null +++ b/.changeset/30311-served-app-labels.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): every Console surface that names an app draws the served app label as served + +Clause-②: no +Metadata-protocol: none + +- **Now.** The app switcher, the sidebar's app title, the top bar's hidden-app menu, Home's app cards, apps strip, recent rows and setup link, the no-access empty state, the AI chat's app names, the marketplace's "Open app" buttons and the report index's other-app links draw the app `label` (and the app card its `description`) the `/meta/app` read served, as given. The server has already translated it, by its label order for an overridden app: a `.custom` pair's own text is kept over the base package's translation. Before, the served label reached the client's `apps..label` catalog lookup only as its fallback, so the base package's translation of the old name won. +- **Unchanged.** A pending draft under `?preview=draft`, a keyed label and an app with no label or description are still named by the catalog key, then by the machine name as before. An unedited app shows the same text as before, because the server serves the catalog's translation for it. diff --git a/ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx b/ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx index 84bbf0047b3..f3482c4888f 100644 --- a/ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx +++ b/ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx @@ -40,8 +40,8 @@ import { Lock, PanelLeft } from 'lucide-react'; // already reaches it through `PreviewDraftEmptyState`, so the eager graph // gains no new module. import { Button, DataEmptyState } from '@object-ui/components'; -import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; -import { resolveKeyedI18nLabel } from '../utils/index.js'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { useServedAppLabel } from '../hooks/useServedAppLabel.js'; export interface AppNoAccessEmptyStateProps { /** The active app, as the console's metadata list carries it. */ @@ -65,9 +65,9 @@ export interface AppNoAccessEmptyStateProps { export function AppNoAccessEmptyState({ app, setupPath, reason = 'no-navigation' }: AppNoAccessEmptyStateProps) { const { t } = useObjectTranslation(); - const { appLabel } = useObjectLabel(); + const { appLabel } = useServedAppLabel(); // The same label the header's app switcher shows for this app. - const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); + const label = appLabel(app); if (reason === 'menu-only') { return ( diff --git a/ui/packages/app-shell/src/console/ai/AiChatPage.tsx b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx index eee0850a1a5..53431b60df0 100644 --- a/ui/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -16,7 +16,8 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useAuth } from '@object-ui/auth'; -import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { useServedAppLabel } from '../../hooks/useServedAppLabel.js'; import { toast } from 'sonner'; import { Package as PackageIcon, Sparkles as SparklesIcon } from 'lucide-react'; import { useAdapter } from '../../providers/AdapterProvider.js'; @@ -2181,7 +2182,7 @@ export function ChatPane({ // Narrow the loosely-typed provider apps once (see MetadataAppItem) so the // derivations below read fields without per-access casts. const metadataApps = apps as MetadataAppItem[] | undefined; - const { appLabel } = useObjectLabel(); + const { appLabel } = useServedAppLabel(); // Fold-insensitive, measured: this walks every message irrespective of role // and reads `draftReview`/`builderHandoff`, which the adapter spreads through. const boundPackageId = useMemo( @@ -2202,7 +2203,7 @@ export function ChatPane({ const app = (metadataApps ?? []).find( (a) => a._packageId === boundPackageId, ); - return app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : boundPackageId; + return app ? appLabel(app) : boundPackageId; }, [boundPackageId, metadataApps, appLabel, t]); // ADR-0057 A1.b edit mode — the resolved name of the app being EDITED @@ -2214,7 +2215,7 @@ export function ChatPane({ const app = (metadataApps ?? []).find( (a) => a._packageId === editPackageId, ); - return app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : undefined; + return app ? appLabel(app) : undefined; }, [editPackageId, metadataApps, appLabel, t]); // objectui#11658 — the plan card's extend-mode scope chip names the existing @@ -2223,7 +2224,7 @@ export function ChatPane({ const resolveAppLabel = useCallback( (appName: string) => { const app = (metadataApps ?? []).find((a) => a.name === appName); - return app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : undefined; + return app ? appLabel(app) : undefined; }, [metadataApps, appLabel, t], ); @@ -2387,7 +2388,7 @@ export function ChatPane({ if (!pkg || byPackage.has(pkg) || isPlatformBuiltinApp(app)) { continue; } - byPackage.set(pkg, appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) })); + byPackage.set(pkg, appLabel(app)); } return Array.from(byPackage, ([id, label]) => ({ id, label })); }, [metadataApps, appLabel, t]); diff --git a/ui/packages/app-shell/src/console/home/AppCard.tsx b/ui/packages/app-shell/src/console/home/AppCard.tsx index 5ca3b41a102..7c178ce135b 100644 --- a/ui/packages/app-shell/src/console/home/AppCard.tsx +++ b/ui/packages/app-shell/src/console/home/AppCard.tsx @@ -8,8 +8,9 @@ import { Star, StarOff, ArrowUpRight } from 'lucide-react'; import { Card, CardContent, Button, Badge } from '@object-ui/components'; -import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; -import { resolveKeyedI18nLabel, appRouteSegment } from '../../utils/index.js'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { useServedAppLabel } from '../../hooks/useServedAppLabel.js'; +import { appRouteSegment } from '../../utils/index.js'; import { useFavorites } from '../../hooks/useFavorites.js'; import { getIcon } from '../../utils/getIcon.js'; import { cn } from '@object-ui/components'; @@ -40,12 +41,12 @@ function hashStr(s: string): number { export function AppCard({ app, onClick, isFavorite, index = 0 }: AppCardProps) { const { t } = useObjectTranslation(); - const { appLabel, appDescription } = useObjectLabel(); + const { appLabel, appDescription } = useServedAppLabel(); const { toggleFavorite } = useFavorites(); const Icon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); - const description = appDescription({ name: app.name, description: resolveKeyedI18nLabel(app.description, t) }); + const label = appLabel(app); + const description = appDescription(app); const accent = ACCENTS[(hashStr(app.name) + index) % ACCENTS.length]; const handleToggleFavorite = (e: React.MouseEvent) => { diff --git a/ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx b/ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx index 3867ad26587..14ef1607eab 100644 --- a/ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx +++ b/ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx @@ -40,8 +40,8 @@ import { useMemo, useState, type ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { LayoutGrid, Plus, Settings, Star } from 'lucide-react'; import { cn } from '@object-ui/components'; -import { useDisplayLocale, useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; -import { resolveKeyedI18nLabel } from '../../utils/index.js'; +import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; +import { useServedAppLabel } from '../../hooks/useServedAppLabel.js'; import { appAddressSegment } from '../../utils/appRoute.js'; import { getIcon } from '../../utils/getIcon.js'; import { timeAgo } from '../../utils/relativeTime.js'; @@ -168,7 +168,7 @@ export function HomeAppsStrip({ lastOpened?: ReadonlyMap; }) { const { t } = useObjectTranslation(); - const { appLabel } = useObjectLabel(); + const { appLabel } = useServedAppLabel(); const displayLocale = useDisplayLocale(); const [showAll, setShowAll] = useState(false); @@ -228,7 +228,7 @@ export function HomeAppsStrip({ {/* `-ml-2` puts the first icon under the heading: a tile pads its icon 8 px. */}
{visible.map((app) => { - const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); + const label = appLabel(app); const fav = favNames.has(app.name); const openedAgo = timeAgo(lastOpened?.get(app.name), displayLocale, TILE_OPENED_TIME_FORMAT); return ( diff --git a/ui/packages/app-shell/src/console/home/HomePage.tsx b/ui/packages/app-shell/src/console/home/HomePage.tsx index 1febf4bc9dd..8b0bb8d5c65 100644 --- a/ui/packages/app-shell/src/console/home/HomePage.tsx +++ b/ui/packages/app-shell/src/console/home/HomePage.tsx @@ -24,8 +24,7 @@ import { useNavTargetLabel, type NavTargetLabelOptions } from '../../hooks/useNa import { readObjectsByName } from '../../hooks/useObjectPresence.js'; import { useFavorites } from '../../hooks/useFavorites.js'; import { useObjectTranslation } from '@object-ui/i18n'; -// Its own line: the line above is the pack-hook binding `perm-home-namespace-3546.test.tsx` reads. -import { useObjectLabel } from '@object-ui/i18n'; +import { useServedAppLabel } from '../../hooks/useServedAppLabel.js'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; import { useAgents, isAskAgent, agentHasCapability } from '@object-ui/plugin-chatbot/core'; // May this session author metadata? Shared with the chat surfaces since the @@ -46,7 +45,6 @@ import { filterActiveApps, matchAppBySegment, resolveHostAppSegment, - resolveKeyedI18nLabel, } from '../../utils/index.js'; import { useInboxBell } from '../../hooks/useInboxBell.js'; import { openInboxNotification } from '../../layout/openInboxNotification.js'; @@ -364,7 +362,7 @@ export function HomePage() { // objectstack#29121 — by name: the object a row names is read alone (as its // icon below is), so Home never loads the whole registry for its rows. const targetLabel = useNavTargetLabel(BY_NAME); - const { appLabel } = useObjectLabel(); + const { appLabel } = useServedAppLabel(); const { favorites } = useFavorites(); const { user } = useAuth(); const { isAdmin, isResolved: isAdminResolved } = useWorkspaceAdminStatus(); @@ -509,7 +507,7 @@ export function HomePage() { item.type === 'record' && objectName ? targetLabel({ kind: 'object', objectName }) ?? objectName : recentItemTypeLabel(t, item.type), - app: app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : undefined, + app: app ? appLabel(app) : undefined, icon: typeof objectIcon === 'string' && objectIcon !== '' ? objectIcon : undefined, }; }; @@ -804,7 +802,7 @@ export function HomePage() { settingsHref={setupApp ? setupPath : undefined} settingsLabel={ setupApp - ? appLabel({ name: setupApp.name, label: resolveKeyedI18nLabel(setupApp.label, t) }) + ? appLabel(setupApp) : undefined } lastOpened={lastOpened} diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx index 58118ccda27..93ffb6aa1f7 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx @@ -46,8 +46,9 @@ import { } from '@object-ui/components'; import { ArrowLeft, ExternalLink, Download, AlertCircle, Package, Trash2, MoreHorizontal, CheckCircle2, ArrowUpCircle, Database, Loader2 } from 'lucide-react'; import { useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; +import { useObjectTranslation } from '@object-ui/i18n'; import { useDisplayLocale } from '@object-ui/i18n'; +import { useServedAppLabel } from '../../hooks/useServedAppLabel.js'; import { useAdapter } from '@object-ui/react'; import { PackageIcon } from './PackageIcon.js'; import { MarkdownText } from './MarkdownText.js'; @@ -84,7 +85,7 @@ import { useDependencyMissingText } from './dependencyMissing.js'; import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js'; import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; import { useMetadata } from '../../providers/MetadataProvider.js'; -import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js'; +import { appRouteSegment, filterActiveApps } from '../../utils/index.js'; import { waitForServedApp, type ServedApp } from './waitForServedApp.js'; import { SuggestedBindingsPanel, type SuggestedBindingsStrings } from '../../components/SuggestedBindingsPanel.js'; import { ConnectorCredentialsStep } from './ConnectorCredentialsStep.js'; @@ -186,7 +187,7 @@ export function MarketplacePackagePage() { // The adapter `MetadataProvider` reads through: the deploy wait asks the // same `GET /meta/app` the cache would, without the cache persisting it. const adapter = useAdapter(); - const { appLabel } = useObjectLabel(); + const { appLabel } = useServedAppLabel(); // The runtime's own answer, read once per render -- the same read the // catalog page makes (objectui#5504). `false` means this runtime mounts no // marketplace at all, so there is no package to fetch and nothing to @@ -969,8 +970,8 @@ export function MarketplacePackagePage() { diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx index d17a32e5d2f..d0ec1afc1d2 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx @@ -26,7 +26,8 @@ import { Package, Search, Store, AlertCircle, CheckCircle2, Settings, Loader2, ChevronDown, Copy, Check, } from 'lucide-react'; import { useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useDisplayLocale, useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; +import { useServedAppLabel } from '../../hooks/useServedAppLabel.js'; import { useAdapter } from '@object-ui/react'; import { PackageIcon } from './PackageIcon.js'; import { MarketplaceAccessDenied } from './MarketplaceAccessDenied.js'; @@ -47,7 +48,7 @@ import { import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js'; import { PageHeader } from '../../layout/PageHeader.js'; import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; -import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js'; +import { appRouteSegment, filterActiveApps } from '../../utils/index.js'; import { waitForServedApp, type ServedApp } from './waitForServedApp.js'; import { splitPackageSuffix } from './middleTruncate.js'; import { useDependencyMissingText } from './dependencyMissing.js'; @@ -309,7 +310,7 @@ export function MarketplacePage() { const { isAdmin, isResolved } = useWorkspaceAdminStatus(); const { t, language } = useObjectTranslation(); const dependencyMissingText = useDependencyMissingText(); - const { appLabel } = useObjectLabel(); + const { appLabel } = useServedAppLabel(); // The adapter `MetadataProvider` reads through: the deploy wait asks the // same `GET /meta/app` the cache would, without the cache persisting it. const adapter = useAdapter(); @@ -619,8 +620,8 @@ export function MarketplacePage() { diff --git a/ui/packages/app-shell/src/hooks/__tests__/useServedAppLabel-30311.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useServedAppLabel-30311.test.tsx new file mode 100644 index 00000000000..86305e4d644 --- /dev/null +++ b/ui/packages/app-shell/src/hooks/__tests__/useServedAppLabel-30311.test.tsx @@ -0,0 +1,142 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30311 — an app the server served is named as served. + * + * Since objectstack#30353 `/meta/app` serves an overridden app (a `.custom` + * pair) under the ruled label order: the override's own text over the base + * package's translation. Every console surface that names an app handed that + * served label to `useObjectLabel().appLabel` as its FALLBACK, and the bundle's + * `apps..label` — the base package's string — won it back. Now the served + * label is drawn as given, and the bundle is asked only for text no server + * translated. + * + * Subject: the real `useServedAppLabel` over the real `useObjectLabel`, inside a + * real `I18nProvider` holding the packaged catalogue, as the console loads it. + * + * Directions, written before the run: the renamed cells RED before the change + * (the bundle's packaged string in both languages), GREEN after; the unedited, + * draft, keyed, unlabelled and description-only cells GREEN on both sides. + */ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { I18nProvider } from '@object-ui/i18n'; +import { useServedAppLabel } from '../useServedAppLabel'; + +/** The packaged catalogue: the base package translates its app. */ +const BUNDLE = { + en: { crm: { apps: { crm: { label: 'CRM', description: 'Sell more' } } } }, + 'zh-CN': { crm: { apps: { crm: { label: '客户管理', description: '多卖一点' } } } }, +}; +const PACKAGED = { en: 'CRM', 'zh-CN': '客户管理' } as const; +const PACKAGED_DESCRIPTION = { en: 'Sell more', 'zh-CN': '多卖一点' } as const; +const RENAMED = 'Pipeline Desk (renamed-30311)'; +const LANGUAGES = ['en', 'zh-CN'] as const; + +function labelsIn(language: 'en' | 'zh-CN') { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return renderHook(() => useServedAppLabel(), { wrapper }).result.current; +} + +describe('useServedAppLabel — a served app label is drawn as served (objectstack#30311)', () => { + it.each(LANGUAGES)('%s: an overridden app shows the label the server served, not the base package\'s', (language) => { + const { appLabel } = labelsIn(language); + expect(appLabel({ name: 'crm', label: RENAMED })).toBe(RENAMED); + }); + + it.each(LANGUAGES)('%s: an unedited app shows the translation the server put in', (language) => { + const { appLabel } = labelsIn(language); + expect(appLabel({ name: 'crm', label: PACKAGED[language] })).toBe(PACKAGED[language]); + }); + + it('zh-CN: an inline locale map is resolved in the active language', () => { + const { appLabel } = labelsIn('zh-CN'); + expect(appLabel({ name: 'crm', label: { en: 'Pipeline Desk', 'zh-CN': '管道台' } })).toBe('管道台'); + }); + + it.each(LANGUAGES)('%s: control — a pending draft under ?preview=draft is named by the bundle', (language) => { + const { appLabel } = labelsIn(language); + expect(appLabel({ name: 'crm', label: RENAMED, _draft: true })).toBe(PACKAGED[language]); + }); + + it.each(LANGUAGES)('%s: control — a keyed label is not served text and keeps the bundle', (language) => { + const { appLabel } = labelsIn(language); + const keyed = { key: 'crm.unknown_key', defaultValue: 'Fallback' }; + expect(appLabel({ name: 'crm', label: keyed })).toBe(PACKAGED[language]); + // With no bundle entry for the app, the key's defaultValue names it, as before. + expect(appLabel({ name: 'other', label: keyed })).toBe('Fallback'); + }); + + it.each(LANGUAGES)('%s: control — an app with no label is named by the bundle, else its machine name', (language) => { + const { appLabel } = labelsIn(language); + expect(appLabel({ name: 'crm' })).toBe(PACKAGED[language]); + expect(appLabel({ name: 'other' })).toBe('other'); + expect(appLabel({ name: 'crm', label: '' })).toBe(PACKAGED[language]); + }); +}); + +describe('useServedAppLabel — a served app description is drawn as served (objectstack#30311)', () => { + it.each(LANGUAGES)('%s: an overridden description shows as served', (language) => { + const { appDescription } = labelsIn(language); + expect(appDescription({ name: 'crm', description: 'Deals, fast (renamed-30311)' })).toBe('Deals, fast (renamed-30311)'); + }); + + it.each(LANGUAGES)('%s: control — a translation-only description still comes from the bundle', (language) => { + const { appDescription } = labelsIn(language); + expect(appDescription({ name: 'crm' })).toBe(PACKAGED_DESCRIPTION[language]); + expect(appDescription({ name: 'crm', description: 'Draft text', _draft: true })).toBe(PACKAGED_DESCRIPTION[language]); + expect(appDescription({ name: 'other' })).toBeUndefined(); + }); +}); + +/** + * The census that keeps the class closed: no app-shell source names an app + * through the bundle's `appLabel` / `appDescription` directly — every surface + * goes through `useServedAppLabel`, which asks the bundle only for text no + * server translated. Read by name, not by intent: a surface that reaches the + * bundle another way is left to review. + */ +describe('useServedAppLabel — no app-shell surface names an app through the bundle directly (objectstack#30311)', () => { + const HERE = dirname(fileURLToPath(import.meta.url)); + const SRC = resolve(HERE, '../..'); + const sources = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return entry.name === '__tests__' ? [] : sources(path); + return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) ? [path] : []; + }); + const BUNDLE_APP_READ = /\{[^}]*\bapp(?:Label|Description)\b[^}]*\}\s*=\s*useObjectLabel\(\)/; + + it('reads every source file under app-shell/src', () => { + // Positive control: the walk reaches the hook itself and a known caller. + const files = sources(SRC).map((p) => relative(SRC, p)); + expect(files).toContain(join('hooks', 'useServedAppLabel.ts')); + expect(files).toContain(join('layout', 'AppSwitcher.tsx')); + }); + + it('none destructures appLabel or appDescription from useObjectLabel()', () => { + // The hook itself is the one reader of the bundle's app lookups. + const offenders = sources(SRC) + .map((p) => relative(SRC, p)) + .filter((p) => p !== join('hooks', 'useServedAppLabel.ts')) + .filter((p) => BUNDLE_APP_READ.test(readFileSync(join(SRC, p), 'utf8'))); + expect(offenders).toEqual([]); + }); + + it('control — the pattern matches the shape it bans', () => { + expect(BUNDLE_APP_READ.test('const { appLabel } = useObjectLabel();')).toBe(true); + expect(BUNDLE_APP_READ.test('const { objectLabel, appDescription } = useObjectLabel();')).toBe(true); + expect(BUNDLE_APP_READ.test('const { objectLabel } = useObjectLabel();')).toBe(false); + // The hook's own read is the shape too, which is why it alone is exempt. + const hook = readFileSync(join(SRC, 'hooks', 'useServedAppLabel.ts'), 'utf8'); + expect(BUNDLE_APP_READ.test(hook)).toBe(true); + }); +}); diff --git a/ui/packages/app-shell/src/hooks/useServedAppLabel.ts b/ui/packages/app-shell/src/hooks/useServedAppLabel.ts new file mode 100644 index 00000000000..ec92d043cb1 --- /dev/null +++ b/ui/packages/app-shell/src/hooks/useServedAppLabel.ts @@ -0,0 +1,85 @@ +/** + * 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. + */ + +/** + * useServedAppLabel — what an app is called, and how it describes itself, on + * every console surface that names an app (objectstack#30311). + * + * An app the `/meta/app` read served is already translated: `translateApp` in + * `@objectstack/spec` writes its `label` and `description` for the request's + * locale, and for an overridden app (a `.custom` pair) in the ruled label + * order, the override's own text kept over the base package's translation + * (objectstack#30353). So a served label is drawn as given. Running the + * client bundle's `apps..label` lookup over it was a second translation + * pass in which the base package's string won a rename back. + * + * The bundle (`useObjectLabel().appLabel` / `appDescription`) is asked only + * for text no server translated: a pending draft under `?preview=draft` + * (the item carries `_draft: true`), a keyed label (`{ key, defaultValue }`, + * resolved through `t` as before), or an app with no label or description at + * all. An unedited app reads the same text as before, because the server + * serves the bundle's translation for it. + */ + +import { useCallback, useMemo } from 'react'; +import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { resolveKeyedI18nLabel } from '../utils/index.js'; +import { metadataText } from './useNavTargetLabel.js'; + +/** The members of an app record this hook reads. */ +export interface ServedAppSource { + name: string; + label?: unknown; + description?: unknown; + _draft?: unknown; +} + +type KeyedLabel = { key: string; defaultValue?: string; params?: Record }; + +/** A console-keyed label (`{ key, defaultValue }`): a key, not served text. */ +function isKeyedLabel(value: unknown): value is KeyedLabel { + return !!value && typeof value === 'object' && typeof (value as { key?: unknown }).key === 'string'; +} + +/** `label` / `description` as the server translated it, or `undefined` when it served none. */ +function servedText(app: ServedAppSource, value: unknown, language: string | undefined): string | undefined { + if (app._draft === true || isKeyedLabel(value)) return undefined; + return metadataText(value, language); +} + +/** The bundle's input for a value that is not served text: a keyed label through `t`, else a plain string. */ +function bundleFallback(value: unknown, t: (key: string, options?: any) => string): string | undefined { + if (isKeyedLabel(value)) return resolveKeyedI18nLabel(value, t); + return typeof value === 'string' ? value : undefined; +} + +export interface ServedAppLabels { + /** The app's display name: the served label, else the bundle's. */ + appLabel: (app: ServedAppSource) => string; + /** The app's description: the served one, else the bundle's; `undefined` when neither names one. */ + appDescription: (app: ServedAppSource) => string | undefined; +} + +export function useServedAppLabel(): ServedAppLabels { + const { language, t } = useObjectTranslation(); + const { appLabel: bundleAppLabel, appDescription: bundleAppDescription } = useObjectLabel(); + + const appLabel = useCallback( + (app: ServedAppSource) => + servedText(app, app.label, language) + ?? bundleAppLabel({ name: app.name, label: bundleFallback(app.label, t) }), + [language, t, bundleAppLabel], + ); + const appDescription = useCallback( + (app: ServedAppSource) => + servedText(app, app.description, language) + ?? bundleAppDescription({ name: app.name, description: bundleFallback(app.description, t) }), + [language, t, bundleAppDescription], + ); + return useMemo(() => ({ appLabel, appDescription }), [appLabel, appDescription]); +} diff --git a/ui/packages/app-shell/src/layout/AppHeader.tsx b/ui/packages/app-shell/src/layout/AppHeader.tsx index 6f0aa789089..8eec055f8df 100644 --- a/ui/packages/app-shell/src/layout/AppHeader.tsx +++ b/ui/packages/app-shell/src/layout/AppHeader.tsx @@ -85,12 +85,13 @@ import { AppSwitcher } from './AppSwitcher.js'; import type { ConnectionState } from '@object-ui/data-objectstack'; import { useAdapter } from '../providers/AdapterProvider.js'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; +import { useServedAppLabel } from '../hooks/useServedAppLabel.js'; import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types'; import { useAuth, getUserInitials } from '@object-ui/auth'; import { useMetadata } from '../providers/MetadataProvider.js'; import { useBundledReads } from '../providers/bundledReads.js'; import { readItemPreferLocal, useReadItemsByName } from '../hooks/useObjectPresence.js'; -import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appStudioRoutePath } from '../utils/index.js'; +import { preferLocal, matchAppBySegment, appStudioRoutePath } from '../utils/index.js'; import { appAddressSegment } from '../utils/appRoute.js'; import { getIcon } from '../utils/getIcon.js'; import { useNavigationContext } from '../context/NavigationContext.js'; @@ -474,7 +475,8 @@ export function AppHeader({ // Same signal as the FAB and the `/ai` route guard. const { enabled: aiEnabled } = useAiSurfaceEnabled(); const { t, language } = useObjectTranslation(); - const { objectPluralLabel, dashboardLabel, pageLabel, reportLabel, viewLabel, appLabel } = useObjectLabel(); + const { objectPluralLabel, dashboardLabel, pageLabel, reportLabel, viewLabel } = useObjectLabel(); + const { appLabel } = useServedAppLabel(); // The dashboard, page and report lists are LAZY getters: reading one starts // its `/meta/:type` read. They are read below only on the route type that // names one (the phone bar's title), never destructured here, so a route of @@ -1324,7 +1326,7 @@ export function AppHeader({ .filter((a: any) => a.active !== false && a.hidden === true && a.name !== 'account') .map((app: any) => { const AppIcon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); + const label = appLabel(app); return ( {switchApps.map((app: any) => { - const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); + const label = appLabel(app); const isActive = activeApp.name === app.name; return ( {/* The Setup app's own label, as its header draws it once it is open, so the entry and the app it opens share one name. */} - {appLabel({ name: setupApp.name, label: resolveKeyedI18nLabel(setupApp.label, t) }) || + {appLabel(setupApp) || t('layout.appSwitcher.settings', { defaultValue: 'Settings' })} {activeApp === setupApp && } diff --git a/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx b/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx index 46fe6d4b127..fb0990afa4d 100644 --- a/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -52,7 +52,7 @@ import { useNavActionDispatch } from '../hooks/useNavActionDispatch.js'; import { useNavTargetLabel } from '../hooks/useNavTargetLabel.js'; import { useNavDocTargetCheck } from '../hooks/useNavDocTargetCheck.js'; import { useNavMemberGates } from '../hooks/useNavMemberGates.js'; -import { matchAppBySegment, resolveKeyedI18nLabel } from '../utils/index.js'; +import { matchAppBySegment } from '../utils/index.js'; import { appAddressSegment } from '../utils/appRoute.js'; import { withoutUnreadableObjectEntries } from '../utils/navObjectReadGate.js'; import { useHomePath } from '../hooks/useHomePath.js'; @@ -61,10 +61,10 @@ import { useHomePath } from '../hooks/useHomePath.js'; // @objectstack/spec 17.0.0-rc.6) for the INLINE per-locale map form of // `I18nLabel`, not for a translation-key ref. import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; -import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; -// useObjectLabel provides appLabel/appDescription for convention-based -// i18n lookup — `{ns}.apps.{name}.label` resolves to the translated label -// loaded from /api/v1/i18n/translations/:locale. +import { useObjectTranslation } from '@object-ui/i18n'; +// The served app label as given; the `{ns}.apps.{name}.label` bundle lookup +// only for an app no server translated (objectstack#30311). +import { useServedAppLabel } from '../hooks/useServedAppLabel.js'; import { useNavigationContext } from '../context/NavigationContext.js'; import { useAppContextSelectors, @@ -173,7 +173,7 @@ export function UnifiedSidebar({ activeAppName, railOverlay = false }: UnifiedSi const { isMobile, setOpenMobile } = useSidebar(); const location = useLocation(); const { t, language } = useObjectTranslation(); - const { appLabel } = useObjectLabel(); + const { appLabel } = useServedAppLabel(); const { context, currentAppName } = useNavigationContext(); const { user, activeOrganization } = useAuth(); const { isAdmin: isWorkspaceAdmin } = useWorkspaceAdminStatus(); @@ -587,7 +587,7 @@ export function UnifiedSidebar({ activeAppName, railOverlay = false }: UnifiedSi
- {appLabel({ name: activeApp.name, label: resolveKeyedI18nLabel(activeApp.label, t) })} + {appLabel(activeApp)} setOpenMobile(false)} data-testid="mobile-sidebar-home" aria-label={t('layout.appSwitcher.allApps', { defaultValue: 'All apps' })} title={t('layout.appSwitcher.allApps', { defaultValue: 'All apps' })} className="inline-flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"> diff --git a/ui/packages/app-shell/src/views/AppReportIndexView.tsx b/ui/packages/app-shell/src/views/AppReportIndexView.tsx index 8e8c3b8666f..be20c1ffa6a 100644 --- a/ui/packages/app-shell/src/views/AppReportIndexView.tsx +++ b/ui/packages/app-shell/src/views/AppReportIndexView.tsx @@ -36,10 +36,10 @@ import { useId, useState, type ReactNode } from 'react'; import { Link, useParams } from 'react-router-dom'; import { BarChart3, ChevronDown, ChevronRight, Loader2, Search } from 'lucide-react'; import { Empty, EmptyTitle, EmptyDescription, Input, cn } from '@object-ui/components'; -import { useDisplayLocale, useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; +import { useServedAppLabel } from '../hooks/useServedAppLabel.js'; import { useMetadata } from '../providers/MetadataProvider.js'; import { useLauncherApps } from '../hooks/useLauncherApps.js'; -import { resolveKeyedI18nLabel } from '../utils/index.js'; import { appAddressSegment } from '../utils/appRoute.js'; import { useExpressionContext } from '../providers/ExpressionProvider.js'; import { useNavMemberGates } from '../hooks/useNavMemberGates.js'; @@ -92,7 +92,7 @@ export function AppReportIndexView() { const { apps: metadataApps } = useMetadata(); const { apps: launcherApps } = useLauncherApps(metadataApps); const otherApps = useOtherAppsWithReports(launcherApps, app); - const { appLabel } = useObjectLabel(); + const { appLabel } = useServedAppLabel(); if (loading) { return ( @@ -202,7 +202,7 @@ export function AppReportIndexView() { data-testid={`report-index-other-app-${String(other.name)}`} > - {appLabel({ name: String(other.name), label: resolveKeyedI18nLabel(other.label, t) })} + {appLabel({ ...other, name: String(other.name) })} ))}