From 21bec51bad0b0fbb262f864e9483dfdd0b685ba4 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 4 Nov 2026 17:39:41 +0000 Subject: [PATCH] feat(ui): show add-on titles and descriptions in the language of the user The library cards, the first-run list, the install dialog and the upgrade dialogs now name an add-on by its translated title and description, taken from the library entry or the installed record. A language without text, and English, keep the English text, field by field. The installed list and the library page are in the alphabetical order of the language of the user, and a search finds an add-on by its translated name and by its English name. The Home commands test and the provider strings test read the English title now that the title of an add-on is a language map. Assisted-by: Claude:claude-sonnet-5-5 javascript --- core/http/react-ui/e2e/addons-gallery.spec.js | 75 +++++++++++++++++++ .../src/components/addons/AppsView.jsx | 12 ++- .../src/components/addons/BrowseView.jsx | 10 ++- .../src/components/addons/FirstRun.jsx | 6 +- .../src/components/addons/addonState.js | 38 ++++++++++ .../src/components/catalog/CatalogView.jsx | 5 +- core/http/react-ui/src/utils/addonTiles.js | 16 +++- core/http/react-ui/src/utils/addonsApi.js | 3 + .../tests/unit/addonhomecommands.test.mjs | 2 +- core/http/react-ui/tests/unit/addons.test.mjs | 71 +++++++++++++++++- .../tests/unit/providerstrings.test.mjs | 7 +- 11 files changed, 227 insertions(+), 18 deletions(-) diff --git a/core/http/react-ui/e2e/addons-gallery.spec.js b/core/http/react-ui/e2e/addons-gallery.spec.js index 0631ef60bd..2d86e423dd 100644 --- a/core/http/react-ui/e2e/addons-gallery.spec.js +++ b/core/http/react-ui/e2e/addons-gallery.spec.js @@ -800,3 +800,78 @@ test.describe('Add-ons: shared catalog components keep the backends gallery', () await expect(page.locator('.ui-empty .ui-empty__title')).toHaveText('No backends found') }) }) + +test.describe('Add-ons: translated titles and descriptions', () => { + const translated = { + ui: { + title: 'Tables', description: 'Databases of tables.', mainPage: 'databases', + titles: { it: 'Tabelle', 'pt-BR': 'Tabelas' }, descriptions: { it: 'Database di tabelle tue o condivise.' }, + }, + } + const library = () => [ + release('tables', { name: 'Tables', description: 'Databases of tables.', ...translated }), + release('voice', { name: 'Voice', description: 'Speak with voices.', ui: { title: 'Voice', description: 'Speak with voices.', titles: { it: 'Voce' } } }), + release('notes', { name: 'Notes', description: 'Keep notes.' }), + ] + const language = (code) => (page) => page.addInitScript(c => localStorage.setItem('localai-language', c), code) + + test('library: the cards show the title and description in the user language, and English per field where a language has none', async ({ page }) => { + await language('it')(page) + await signIn(page, ADMIN) + await mockAddons(page, { installed: [], releases: library() }) + await page.goto('/app/addons?tab=browse') + const tables = page.locator('[data-addon="tables"]') + await expect(tables.locator('.ad-entry__title')).toHaveText('Tabelle') + await expect(tables).toContainText('Database di tabelle tue o condivise.') + const voice = page.locator('[data-addon="voice"]') + await expect(voice.locator('.ad-entry__title')).toHaveText('Voce') + await expect(voice).toContainText('Speak with voices.') + const notes = page.locator('[data-addon="notes"]') + await expect(notes.locator('.ad-entry__title')).toHaveText('Notes') + await expect(notes).toContainText('Keep notes.') + await shoot(page, 'translations-library-it') + }) + + test('library: English and an unlisted language keep the English text', async ({ page }) => { + await language('de')(page) + await signIn(page, ADMIN) + await mockAddons(page, { installed: [], releases: library() }) + await page.goto('/app/addons?tab=browse') + await expect(page.locator('[data-addon="tables"] .ad-entry__title')).toHaveText('Tables') + await expect(page.locator('[data-addon="tables"]')).toContainText('Databases of tables.') + }) + + test('library: the install dialog names the add-on in the user language', async ({ page }) => { + await language('it')(page) + await signIn(page, ADMIN) + await mockAddons(page, { installed: [], releases: library() }) + await page.goto('/app/addons?tab=browse') + await page.locator('[data-addon="tables"]').getByRole('button').first().click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Tabelle') + await expect(dialog).not.toContainText('Tables?') + }) + + test('installed list: tiles show the translated name and a search finds it by the translated or the English name', async ({ page }) => { + await language('it')(page) + await signIn(page, MEMBER, { addons: true }) + await mockAddons(page, { apps: [ + app({ id: 'tables', name: 'tables', title: 'Tables', titles: { en: 'Tables', it: 'Tabelle' }, description: 'Databases.', descriptions: { it: 'Database.' } }), + app({ id: 'voice', name: 'voice', title: 'Voice', titles: { it: 'Voce' } }), + ] }) + await page.goto('/app/addons') + await expect(page.locator('[data-app="tables"] .ad-tile__title')).toHaveText('Tabelle') + await expect(page.locator('[data-app="voice"] .ad-tile__title')).toHaveText('Voce') + const search = page.getByRole('searchbox').first() + for (const [text, shown, hidden] of [['tabelle', 'tables', 'voice'], ['TABLES', 'tables', 'voice'], ['voce', 'voice', 'tables'], ['voice', 'voice', 'tables']]) { + await search.fill(text) + await expect(page.locator(`[data-app="${shown}"]`)).toHaveCount(1) + await expect(page.locator(`[data-app="${hidden}"]`)).toHaveCount(0) + } + await search.fill('') + await page.goto('/app/addons?detail=tables') + await expect(page.getByTestId('addon-detail')).toContainText('Tabelle') + await expect(page.getByTestId('addon-detail')).toContainText('Database.') + await shoot(page, 'translations-detail-it') + }) +}) diff --git a/core/http/react-ui/src/components/addons/AppsView.jsx b/core/http/react-ui/src/components/addons/AppsView.jsx index a6f5acf19b..fa04c87fcd 100644 --- a/core/http/react-ui/src/components/addons/AppsView.jsx +++ b/core/http/react-ui/src/components/addons/AppsView.jsx @@ -27,12 +27,14 @@ import { useBulkUpgrade } from '../../hooks/useBulkUpgrade' import { updateView, upgradeAvailability } from './updateState' import { upgradeFailureWords } from './updateReview' import FirstRun from './FirstRun' -import { addedByAddon, attentionOf, filterByState, listState, loadLastKnown, saveLastKnown, sortByAttention, staleKind, STATE_FILTERS, upgradingTitles } from './addonState' +import { addedByAddon, attentionOf, filterByState, listState, loadLastKnown, localizedText, localizeRecord, saveLastKnown, sortByAttention, staleKind, STATE_FILTERS, upgradingTitles } from './addonState' import { describeError, describeOutcome } from './errors' import ApproveUpgradeDialog, { WaitingBanner } from './ApproveUpgradeDialog' import { pendingApprovals } from './hostGrantsState' import { useAddonActions } from './useAddonActions' +const NO_RECORDS = {} + // The apps every caller with the add-ons feature may use. The owner decides // what is listed; this view only draws it. The list is refreshed on a bounded // cadence, slower in a hidden tab and backing off while the owner is @@ -118,7 +120,9 @@ export default function AppsView({ api, adapter, onSummary, grants }) { // The installed record of an add-on, for the administrator's Details in the // pane. A change reads it again. const [installedRecords, setInstalledInfo] = useState({}) - const installedInfo = installedRecords.actor === actor ? installedRecords.value : {} + const installedRaw = installedRecords.actor === actor ? installedRecords.value : NO_RECORDS + // The records name an add-on in dialogs and cards: in the user's language. + const installedInfo = useMemo(() => (installedRaw === NO_RECORDS ? NO_RECORDS : Object.fromEntries(Object.entries(installedRaw).map(([id, rec]) => [id, localizeRecord(rec, i18n.language)]))), [installedRaw, i18n.language]) const [installedReload, setInstalledReload] = useState(0) const actions = useAddonActions(api, { onSettled: () => { @@ -249,9 +253,9 @@ export default function AppsView({ api, adapter, onSummary, grants }) { return state.apps.filter(a => a.update === 'update-pending' || updateView(installedInfo[a.id]?.update).state === 'update-available').map(a => { const rec = installedInfo[a.id] const av = upgradeAvailability(rec, { isAdmin, pending: true }) - return { id: a.id, name: a.title || a.id, from: rec ? rec.version : a.version, to: rec ? updateView(rec.update).version : '', startable: av.startable, reason: reasonText(av, t), addon: rec } + return { id: a.id, name: localizedText(a, 'title', i18n.language) || a.id, from: rec ? rec.version : a.version, to: rec ? updateView(rec.update).version : '', startable: av.startable, reason: reasonText(av, t), addon: rec } }) - }, [state.apps, installedInfo, isAdmin, t]) + }, [state.apps, installedInfo, isAdmin, t, i18n.language]) const bulk = useBulkUpgrade({ items: upgradeItems, runOne: async (item) => { diff --git a/core/http/react-ui/src/components/addons/BrowseView.jsx b/core/http/react-ui/src/components/addons/BrowseView.jsx index 8032ba79eb..567f0a1f87 100644 --- a/core/http/react-ui/src/components/addons/BrowseView.jsx +++ b/core/http/react-ui/src/components/addons/BrowseView.jsx @@ -9,7 +9,7 @@ import { AppIcon, UnknownNotice, VersionText } from './AddonBits' import { Button, Notice, StatusChip } from '../ui' import FlowStatus from './InstallFlow' import InstallDialog from './InstallDialog' -import { addedByEntry } from './addonState' +import { addedByEntry, localizeEntry, localizeRecord, sortEntriesByTitle } from './addonState' import { updateView, entryUpdateNote, updateNoticeSeverity, upgradeAvailability } from './updateState' import { describeError, describeOutcome } from './errors' import { useAddonActions } from './useAddonActions' @@ -22,7 +22,7 @@ import { UpdateBadge } from '../upgrade/UpgradeBits' // the other. An add-on that is installed but off offers Turn on. Backends are // not listed here: they belong to the Backends page. export default function BrowseView({ api, adapter }) { - const { t } = useTranslation('addons') + const { t, i18n } = useTranslation('addons') const [params, setParams] = useSearchParams() const [installed, setInstalled] = useState({}) const [reload, setReload] = useState(0) @@ -53,8 +53,9 @@ export default function BrowseView({ api, adapter }) { const openInstall = (entry) => setDialog(entry) - const renderEntry = (entry) => { - const have = installed[entry.id] + const renderEntry = (raw) => { + const entry = localizeEntry(raw, i18n.language) + const have = installed[entry.id] ? localizeRecord(installed[entry.id], i18n.language) : undefined const flow = actions.flows[`flow:${entry.key}`] const pending = Boolean(actions.unknown[entry.id]) const working = ['install', 'turn-on'].includes(flow?.step) @@ -127,6 +128,7 @@ export default function BrowseView({ api, adapter }) { query={query} onQueryChange={onQueryChange} renderEntry={renderEntry} + orderEntries={entries => sortEntriesByTitle(entries, i18n.language)} reloadToken={reload} describeError={e => describeError(e, t)} searchLabel={t('browse.search')} diff --git a/core/http/react-ui/src/components/addons/FirstRun.jsx b/core/http/react-ui/src/components/addons/FirstRun.jsx index b330676765..7555916642 100644 --- a/core/http/react-ui/src/components/addons/FirstRun.jsx +++ b/core/http/react-ui/src/components/addons/FirstRun.jsx @@ -5,14 +5,14 @@ import { Button, Callout, EmptyState, SkeletonRows } from '../ui' import { AppIcon, EntryAdds, VersionText } from './AddonBits' import FlowStatus from './InstallFlow' import InstallDialog from './InstallDialog' -import { featuredEntries } from './addonState' +import { featuredEntries, localizeEntry } from './addonState' import { describeError } from './errors' // Nothing is installed yet. An administrator sees what add-ons are for, the // way to find them, and three add-ons from the sources as rows with Install: // one confirmation, then the install and the turn on run one after the other. export default function FirstRun({ adapter, actions }) { - const { t } = useTranslation('addons') + const { t, i18n } = useTranslation('addons') const [state, setState] = useState({ status: 'loading', entries: [], error: null }) const [confirm, setConfirm] = useState(null) @@ -48,7 +48,7 @@ export default function FirstRun({ adapter, actions }) { <>

{t('apps.firstRun.featuredLabel')}