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 }) { <>