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
5 changes: 5 additions & 0 deletions workspaces/boost/.changeset/ai-catalog-translations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-boost': minor
---

Add German, Spanish, French, Italian, and Japanese translation files for the AI Catalog frontend. Register all five locales as lazy imports in the translation resource. Add focused tests for locale key parity, interpolation placeholder preservation, and Playwright locale coverage.
4 changes: 4 additions & 0 deletions workspaces/boost/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ app:
redirects:
- from: /
to: /ai-catalog
- api:app/app-language:
config:
defaultLanguage: en
availableLanguages: [en, de, es, fr, it, ja]
# Example: disable a built-in filter
# - ai-catalog-filter:boost/owner: false

Expand Down
23 changes: 17 additions & 6 deletions workspaces/boost/e2e-tests/boost.AiCatalogPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
import { expect, test, type Page, type Route } from '@playwright/test';

import { runAccessibilityTests } from './utils/accessibility';
import { skipIfLocales } from './utils/localeSkip';

const NON_EN = ['de', 'es', 'fr', 'it', 'ja'];

/**
* Locators from Playwright MCP against the live NFS app. After catalog load
Expand Down Expand Up @@ -127,6 +130,7 @@ test.describe('Boost AI Catalog', () => {
test('renders the AI Catalog heading after guest sign-in', async ({
page,
}, testInfo) => {
skipIfLocales(testInfo, NON_EN, 'Functional suite is English-only');
await mockCatalogEntities(page, []);
await signInAsGuest(page);

Expand All @@ -144,7 +148,8 @@ test.describe('Boost AI Catalog', () => {

test('shows catalog assets when the catalog API returns items', async ({
page,
}) => {
}, testInfo) => {
skipIfLocales(testInfo, NON_EN, 'Functional suite is English-only');
await mockCatalogEntities(page, [skillEntity]);
await signInAsGuest(page);

Expand All @@ -158,7 +163,8 @@ test.describe('Boost AI Catalog', () => {

test('shows the catalog error state when the catalog API fails', async ({
page,
}) => {
}, testInfo) => {
skipIfLocales(testInfo, NON_EN, 'Functional suite is English-only');
await page.route('**/api/catalog/**', route => route.abort());
await signInAsGuest(page);

Expand All @@ -168,7 +174,8 @@ test.describe('Boost AI Catalog', () => {

test('Type filter keeps only matching cards and sets type in the URL', async ({
page,
}) => {
}, testInfo) => {
skipIfLocales(testInfo, NON_EN, 'Functional suite is English-only');
await loadTwoAssetCatalog(page);

const filters = page.getByRole('navigation', { name: 'Filters' });
Expand Down Expand Up @@ -200,7 +207,8 @@ test.describe('Boost AI Catalog', () => {

test('search keeps only matching cards and sets q in the URL', async ({
page,
}) => {
}, testInfo) => {
skipIfLocales(testInfo, NON_EN, 'Functional suite is English-only');
await loadTwoAssetCatalog(page);

await page.getByRole('searchbox', { name: 'Search' }).fill('Code Review');
Expand All @@ -218,6 +226,7 @@ test.describe('Boost AI Catalog', () => {
test('uses a mobile filter drawer on smaller screens', async ({
page,
}, testInfo) => {
skipIfLocales(testInfo, NON_EN, 'Functional suite is English-only');
await page.setViewportSize({ width: 768, height: 900 });
await loadTwoAssetCatalog(page);

Expand Down Expand Up @@ -254,7 +263,8 @@ test.describe('Boost AI Catalog', () => {

test('table view lists both assets in the data table and sets view=table', async ({
page,
}) => {
}, testInfo) => {
skipIfLocales(testInfo, NON_EN, 'Functional suite is English-only');
await loadTwoAssetCatalog(page);

await page.getByRole('radio', { name: 'Table view' }).click();
Expand All @@ -276,7 +286,8 @@ test.describe('Boost AI Catalog', () => {

test('empty filtered state clears search and restores both cards', async ({
page,
}) => {
}, testInfo) => {
skipIfLocales(testInfo, NON_EN, 'Functional suite is English-only');
await loadTwoAssetCatalog(page);

await page.getByRole('searchbox', { name: 'Search' }).fill('zzznomatch');
Expand Down
162 changes: 162 additions & 0 deletions workspaces/boost/e2e-tests/boost.translations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect, test, type Page, type Route } from '@playwright/test';

import { runAccessibilityTests } from './utils/accessibility';
import { getTranslations, type BoostMessages } from './utils/translations';

const LOCALE_DISPLAY_NAMES: Record<string, string> = {
en: 'English',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
it: 'Italiano',
ja: '日本語',
};

const skillEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'AiResource',
metadata: {
name: 'code-review-skill',
title: 'Code Review Skill',
description: 'Automated code review for common issues.',
namespace: 'default',
uid: 'uid-1',
tags: ['security'],
annotations: { 'rhdh.io/ai-asset-source': 'github' },
},
spec: { type: 'skill', lifecycle: 'production', owner: 'team-ai-platform' },
};

function isCatalogEntitiesPath(url: URL): boolean {
return (
url.pathname.endsWith('/api/catalog/entities') &&
!url.pathname.includes('/by-query')
);
}

async function mockCatalogEntities(page: Page, items: unknown[]) {
const fulfillItemsWrapper = async (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items }),
});

await page.route('**/api/catalog/entities/by-query**', fulfillItemsWrapper);
await page.route(isCatalogEntitiesPath, async route => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(items),
});
return;
}
await fulfillItemsWrapper(route);
});
}

/**
* Sign in as a guest, switch the app language through Settings, and wait for
* the authenticated app shell to render.
*/
async function signInAndSwitchLocale(
page: Page,
locale: string,
): Promise<void> {
page.on('dialog', dialog => dialog.accept());
await page.goto('/');
const enter = page.getByRole('button', { name: 'Enter' });
const settingsLink = page.getByRole('link', { name: 'Settings' });
await expect(enter.or(settingsLink).first()).toBeVisible({
timeout: 30_000,
});
if (await enter.isVisible()) {
await enter.click();
await settingsLink.waitFor({ state: 'visible', timeout: 30_000 });
}

const baseLocale = locale.split('-')[0];
if (baseLocale !== 'en') {
await settingsLink.click();
await page.getByRole('button', { name: 'English' }).click();
await page
.getByRole('option', { name: LOCALE_DISPLAY_NAMES[baseLocale] })
.click();
}
}

test.describe('Boost AI Catalog translations', () => {
test('renders representative strings in the configured locale', async ({
page,
}) => {
const currentLocale = await page.evaluate(
() => globalThis.navigator.language,
);
const baseLocale = currentLocale.split('-')[0];
const translations: BoostMessages = getTranslations(baseLocale);

await mockCatalogEntities(page, [skillEntity]);
await signInAndSwitchLocale(page, currentLocale);

await page.getByRole('link', { name: translations.nav.aiCatalog }).click();

// This heading is rendered by the page component so it updates at runtime.
await expect(
page.getByRole('heading', { name: translations.catalog.page.title }),
).toBeVisible();
// Verify translated controls from both the filter sidebar and toolbar.
await expect(
page.getByRole('navigation', {
name: translations.catalog.filter.title,
}),
).toBeVisible();
await expect(
page.getByRole('searchbox', {
name: translations.catalog.toolbar.search,
}),
).toBeVisible();
});

test('renders empty state in the configured locale', async ({
page,
}, testInfo) => {
const currentLocale = await page.evaluate(
() => globalThis.navigator.language,
);
const baseLocale = currentLocale.split('-')[0];
const translations: BoostMessages = getTranslations(baseLocale);

await mockCatalogEntities(page, []);
await signInAndSwitchLocale(page, currentLocale);

await page.getByRole('link', { name: translations.nav.aiCatalog }).click();

await expect(
page.getByText(translations.catalog.empty.title),
).toBeVisible();
await expect(
page.getByRole('button', { name: translations.catalog.empty.refresh }),
).toBeVisible();

// Accessibility check on the empty state avoids the known
// color-contrast violation on category badges (RHDHBUGS-3738).
await runAccessibilityTests(page, testInfo);
});
});
31 changes: 31 additions & 0 deletions workspaces/boost/e2e-tests/utils/localeSkip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { test, type TestInfo } from '@playwright/test';

/**
* Skips the current test when the project (locale) is in the given list.
* Call at the start of a test so it still runs on other locales.
*/
export function skipIfLocales(
testInfo: TestInfo,
locales: string[],
reason: string,
): void {
if (locales.includes(testInfo.project.name)) {
test.skip(true, reason);
}
}
63 changes: 63 additions & 0 deletions workspaces/boost/e2e-tests/utils/translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// These translation files are not exported by the package, so relative imports are necessary for e2e tests
/* eslint-disable @backstage/no-relative-monorepo-imports */
import { boostMessages } from '../../plugins/boost/src/translations/ref.js';
import boostTranslationDe from '../../plugins/boost/src/translations/de.js';
import boostTranslationEs from '../../plugins/boost/src/translations/es.js';
import boostTranslationFr from '../../plugins/boost/src/translations/fr.js';
import boostTranslationIt from '../../plugins/boost/src/translations/it.js';
import boostTranslationJa from '../../plugins/boost/src/translations/ja.js';
/* eslint-enable @backstage/no-relative-monorepo-imports */

export type BoostMessages = typeof boostMessages;

function transformFlatMessagesIntoTree(
flatMessages: typeof boostTranslationDe.messages,
) {
const messages = {} as Record<string, any>;
for (const key of Object.keys(flatMessages)) {
const path = key.split('.');
let current = messages;
for (let i = 0; i < path.length - 1; i++) {
current[path[i]] = current[path[i]] || {};
current = current[path[i]] as Record<string, any>;
Comment thread
rohitkrai03 marked this conversation as resolved.
}
current[path[path.length - 1]] =

Check warning on line 40 in workspaces/boost/e2e-tests/utils/translations.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `.at(…)` over `[….length - index]`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AaCtzgbiHZoJ28pEm0Et&open=AaCtzgbiHZoJ28pEm0Et&pullRequest=4841
flatMessages[key as keyof typeof flatMessages];
}
return messages as BoostMessages;
}

export function getTranslations(locale: string): BoostMessages {
switch (locale) {
case 'en':
return boostMessages;
case 'de':
return transformFlatMessagesIntoTree(boostTranslationDe.messages);
case 'es':
return transformFlatMessagesIntoTree(boostTranslationEs.messages);
case 'fr':
return transformFlatMessagesIntoTree(boostTranslationFr.messages);
case 'it':
return transformFlatMessagesIntoTree(boostTranslationIt.messages);
case 'ja':
return transformFlatMessagesIntoTree(boostTranslationJa.messages);
default:
return boostMessages;
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Tasks: AI Catalog frontend translations (RHIDP-15479)

- [x] 1. Translation module auto-discovery entry (`./translations`) already exists
- [x] 2. Create `src/translations/de.ts`
- [x] 3. Create `src/translations/es.ts`
- [x] 4. Create `src/translations/fr.ts`
- [x] 5. Create `src/translations/it.ts`
- [x] 6. Create `src/translations/ja.ts`
- [x] 7. Register lazy locale imports in `src/translations/index.ts`
- [x] 8. Audit user-facing strings against `ref.ts`
- [x] 9. Preserve interpolation placeholders in all locale files
- [ ] 10. ~~Verify locale switching in the dev app~~ — deferred: requires a live RHDH instance with the Settings language selector
- [ ] 11. ~~Verify English fallback for missing keys~~ — deferred: requires a live RHDH instance; Backstage's `createTranslationRef` provides English fallback by design
Loading
Loading