diff --git a/.changeset/chilled-beds-exist.md b/.changeset/chilled-beds-exist.md new file mode 100644 index 0000000000..2023f6fb71 --- /dev/null +++ b/.changeset/chilled-beds-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-graphiql': patch +--- + +Add support for using the FetchApi diff --git a/.changeset/dry-yaks-type.md b/.changeset/dry-yaks-type.md new file mode 100644 index 0000000000..3e9cdef474 --- /dev/null +++ b/.changeset/dry-yaks-type.md @@ -0,0 +1,8 @@ +--- +'@backstage/backend-tasks': patch +--- + +When starting a task that existed before, with a faster schedule than it +previously had, the task will now correctly obey the faster schedule +immediately. Before this fix, the new schedule was only obeyed after the next +pending (according to the old schedule) run had completed. diff --git a/.changeset/nine-taxis-study.md b/.changeset/nine-taxis-study.md new file mode 100644 index 0000000000..693534c2be --- /dev/null +++ b/.changeset/nine-taxis-study.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Minor internal tweak to handle `classnames` update diff --git a/.changeset/old-windows-argue.md b/.changeset/old-windows-argue.md new file mode 100644 index 0000000000..f687d1b08c --- /dev/null +++ b/.changeset/old-windows-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': minor +--- + +Slight change to the `PreparePullRequestFormProps`, because of an update to `react-hook-form`. diff --git a/.changeset/pre.json b/.changeset/pre.json index 6c1673b0be..d824680e8f 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,5 +1,5 @@ { - "mode": "pre", + "mode": "exit", "tag": "next", "initialVersions": { "example-app": "0.2.86", diff --git a/docs/local-dev/cli-build-system.md b/docs/local-dev/cli-build-system.md index eb1bda79db..b22b6dd284 100644 --- a/docs/local-dev/cli-build-system.md +++ b/docs/local-dev/cli-build-system.md @@ -62,7 +62,7 @@ steps: - **Bundling** - Combines a package and all of its dependencies into a production-ready bundle -These steps are generally kept isolated form each other, with each step focusing +These steps are generally kept isolated from each other, with each step focusing on its specific task. For example, we do not do linting or type checking together with the building or bundling. This is so that we can provide more flexibility and avoid duplicate work, improving performance. It is strongly diff --git a/microsite/data/plugins/search-backend-module-cognitive-search.yaml b/microsite/data/plugins/search-backend-module-cognitive-search.yaml new file mode 100644 index 0000000000..128e5d9167 --- /dev/null +++ b/microsite/data/plugins/search-backend-module-cognitive-search.yaml @@ -0,0 +1,9 @@ +--- +title: Search extension for Azure Cognitive Search +author: AP Communications +authorUrl: https://www.ap-com.co.jp/en/ +category: Search +description: Search extension plugin to create backstage search indexes into Azure Cognitive Search. +documentation: https://github.com/ap-communications/platt-backstage-plugin/tree/main/plugins/search-backend-module-cognitive-search +npmPackageName: '@platt/plugin-search-backend-module-cognitive-search' +addedDate: '2023-09-13' diff --git a/microsite/data/plugins/veecode-github-workflows b/microsite/data/plugins/veecode-github-workflows new file mode 100644 index 0000000000..cd518dd8b8 --- /dev/null +++ b/microsite/data/plugins/veecode-github-workflows @@ -0,0 +1,15 @@ +--- +title: Veecode GitHub Workflows +author: Veecode Platform +authorUrl: https://github.com/veecode-platform +category: CI/CD +description: The Github Workflows plugin provides an alternative for manually triggering GitHub workflows from within your Backstage component. +documentation: https://github.com/veecode-platform/platform-backstage-plugins/tree/master/plugins/github-workflows +iconUrl: https://veecode-platform.github.io/support/imgs/logo_2.svg +npmPackageName: '@veecode-platform/backstage-plugin-github-workflows' +tags: + - ci + - cd + - github + - workflows +addedDate: '2023-09-12' diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index f348d24466..f2fda9882b 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -33,6 +33,7 @@ import { createApiFactory, discoveryApiRef, errorApiRef, + fetchApiRef, githubAuthApiRef, } from '@backstage/core-plugin-api'; import { AuthProxyDiscoveryApi } from './AuthProxyDiscoveryApi'; @@ -53,18 +54,24 @@ export const apis: AnyApiFactory[] = [ createApiFactory({ api: graphQlBrowseApiRef, - deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef }, - factory: ({ errorApi, githubAuthApi }) => + deps: { + errorApi: errorApiRef, + fetchApi: fetchApiRef, + githubAuthApi: githubAuthApiRef, + }, + factory: ({ errorApi, fetchApi, githubAuthApi }) => GraphQLEndpoints.from([ GraphQLEndpoints.create({ id: 'gitlab', title: 'GitLab', url: 'https://gitlab.com/api/graphql', + fetchApi, }), GraphQLEndpoints.github({ id: 'github', title: 'GitHub', errorApi, + fetchApi, githubAuthApi, }), ]), diff --git a/packages/backend-tasks/src/tasks/TaskWorker.test.ts b/packages/backend-tasks/src/tasks/TaskWorker.test.ts index 70be2bf415..71d02274e2 100644 --- a/packages/backend-tasks/src/tasks/TaskWorker.test.ts +++ b/packages/backend-tasks/src/tasks/TaskWorker.test.ts @@ -330,6 +330,48 @@ describe('TaskWorker', () => { const before = fn1.mock.calls.length; await promise2; expect(fn1.mock.calls.length).toBeGreaterThan(before); + + await knex.destroy(); + }, + ); + + it.each(databases.eachSupportedId())( + 'next_run_start_at is always the min between schedule changes, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV2 = { + version: 2, + cadence: '*/15 * * * *', + initialDelayDuration: 'PT2M', + timeoutAfterDuration: 'PT1M', + }; + + const worker = new TaskWorker('task99', fn, knex, logger); + await worker.persistTask(settings); + const row1 = (await knex(DB_TASKS_TABLE))[0]; + + const settings2 = { + ...settings, + cadence: '*/2 * * * *', + initialDelayDuration: 'PT1M', + }; + await worker.persistTask(settings2); + const row2 = (await knex(DB_TASKS_TABLE))[0]; + + expect(row2.next_run_start_at).not.toStrictEqual(row1.next_run_start_at); + + const settings3 = { ...settings }; + await worker.persistTask(settings3); + const row3 = (await knex(DB_TASKS_TABLE))[0]; + + expect(row3.next_run_start_at).toStrictEqual(row2.next_run_start_at); + + await knex.destroy(); }, ); }); diff --git a/packages/backend-tasks/src/tasks/TaskWorker.ts b/packages/backend-tasks/src/tasks/TaskWorker.ts index a03d1ec058..c1007b4964 100644 --- a/packages/backend-tasks/src/tasks/TaskWorker.ts +++ b/packages/backend-tasks/src/tasks/TaskWorker.ts @@ -192,14 +192,36 @@ export class TaskWorker { // It's OK if the task already exists; if it does, just replace its // settings with the new value and start the loop as usual. + const settingsJson = JSON.stringify(settings); await this.knex(DB_TASKS_TABLE) .insert({ id: this.taskId, - settings_json: JSON.stringify(settings), + settings_json: settingsJson, next_run_start_at: startAt, }) .onConflict('id') - .merge(['settings_json']); + .merge( + this.knex.client.config.client.includes('mysql') + ? { + settings_json: settingsJson, + next_run_start_at: this.knex.raw( + `CASE WHEN ?? < ?? THEN ?? ELSE ?? END`, + [startAt, 'next_run_start_at', startAt, 'next_run_start_at'], + ), + } + : { + settings_json: this.knex.ref('excluded.settings_json'), + next_run_start_at: this.knex.raw( + `CASE WHEN ?? < ?? THEN ?? ELSE ?? END`, + [ + 'excluded.next_run_start_at', + `${DB_TASKS_TABLE}.next_run_start_at`, + 'excluded.next_run_start_at', + `${DB_TASKS_TABLE}.next_run_start_at`, + ], + ), + }, + ); } /** diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 84019b55ee..7af6c6160b 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -31,6 +31,9 @@ import { rest } from 'msw'; import { NotFoundError } from '@backstage/errors'; import { Lockfile } from '../../lib/versioning/Lockfile'; +// Avoid mutating the global http(s) agent used in other tests +jest.mock('global-agent/bootstrap', () => {}); + // Remove log coloring to simplify log matching jest.mock('chalk', () => ({ red: (str: string) => str, diff --git a/packages/core-app-api/alpha-api-report.md b/packages/core-app-api/alpha-api-report.md deleted file mode 100644 index f35adfdbaa..0000000000 --- a/packages/core-app-api/alpha-api-report.md +++ /dev/null @@ -1,85 +0,0 @@ -## API Report File for "@backstage/core-app-api" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -import { AppTranslationApi } from '@backstage/core-plugin-api/alpha'; -import { i18n } from 'i18next'; -import { TranslationRef } from '@backstage/core-plugin-api/alpha'; - -// @alpha (undocumented) -export class AppTranslationApiImpl implements AppTranslationApi { - // (undocumented) - addLazyResources>( - translationRef: TranslationRef, - initResources?: Record< - string, - () => Promise<{ - messages: TranslationMessages; - }> - >, - ): void; - // (undocumented) - addResources>( - translationRef: TranslationRef, - initResources?: Record< - string, - TranslationMessages> - >, - ): void; - // (undocumented) - addResourcesByRef>( - translationRef: TranslationRef, - ): void; - // (undocumented) - static create(options?: ExperimentalI18n): AppTranslationApiImpl; - // (undocumented) - getI18n(): i18n; - // (undocumented) - initMessages(options?: ExperimentalI18n): void; -} - -// @alpha (undocumented) -export function createTranslationResource(options: { - ref: T; - messages?: Record>; - lazyMessages: Record< - string, - () => Promise<{ - messages: TranslationMessages; - }> - >; -}): { - ref: T; - messages?: Record> | undefined; - lazyMessages: Record< - string, - () => Promise<{ - messages: TranslationMessages; - }> - >; -}; - -// @alpha (undocumented) -export type ExperimentalI18n = { - supportedLanguages: string[]; - fallbackLanguage?: string | string[]; - messages?: Array<{ - ref: TranslationRef; - messages?: Record>; - lazyMessages: Record< - string, - () => Promise<{ - messages: TranslationMessages; - }> - >; - }>; -}; - -// @alpha (undocumented) -export type TranslationMessages = T extends TranslationRef - ? Partial - : never; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index e53d2eed83..1b5a5b4798 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -64,7 +64,8 @@ import { SessionState } from '@backstage/core-plugin-api'; import { StorageApi } from '@backstage/core-plugin-api'; import { StorageValueSnapshot } from '@backstage/core-plugin-api'; import { SubRouteRef } from '@backstage/core-plugin-api'; -import { TranslationRef } from '@backstage/core-plugin-api/alpha'; +import { TranslationMessages } from '@backstage/core-plugin-api/alpha'; +import { TranslationResource } from '@backstage/core-plugin-api/alpha'; // @public export class AlertApiForwarder implements AlertApi { @@ -221,19 +222,10 @@ export type AppOptions = { themes: (Partial & Omit)[]; configLoader?: AppConfigLoader; bindRoutes?(context: { bind: AppRouteBinder }): void; - __experimentalI18n?: { - supportedLanguages: string[]; + __experimentalTranslations?: { fallbackLanguage?: string | string[]; - messages?: Array<{ - ref: TranslationRef; - messages?: Record>; - lazyMessages: Record< - string, - () => Promise<{ - messages: TranslationMessages; - }> - >; - }>; + supportedLanguages?: string[]; + resources?: Array; }; }; diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 0509af9bd4..c63f64c1ce 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -7,14 +7,10 @@ }, "exports": { ".": "./src/index.ts", - "./alpha": "./src/alpha.ts", "./package.json": "./package.json" }, "typesVersions": { "*": { - "alpha": [ - "src/alpha.ts" - ], "package.json": [ "package.json" ] diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslation.test.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts similarity index 55% rename from packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslation.test.ts rename to packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts index b6eba8a1c7..5b7304d36b 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslation.test.ts +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.test.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { + createTranslationMessages, + createTranslationRef, + createTranslationResource, +} from '@backstage/core-plugin-api/alpha'; import { AppTranslationApiImpl } from './AppTranslationImpl'; import i18next from 'i18next'; @@ -70,112 +74,68 @@ describe('AppTranslationApiImpl', () => { }); it('should init messages correctly', () => { - const useResourcesMock = jest.spyOn( + const addMessagesMock = jest.spyOn( AppTranslationApiImpl.prototype, - 'addResources', + 'addMessages', ); - const useLazyResourcesMock = jest.spyOn( + const addLazyResourcesMock = jest.spyOn( AppTranslationApiImpl.prototype, 'addLazyResources', ); const ref = createTranslationRef({ id: 'ref-id', messages: { - key1: '', + key: '', }, }); - const options = { - supportedLanguages: ['en'], - messages: [ - { - ref, - messages: { - en: { key1: 'value1' }, - }, - lazyMessages: { - en: () => Promise.resolve({ key2: 'value2' }), - } as any, - }, - ], - }; - - const instance = AppTranslationApiImpl.create({ - supportedLanguages: ['en'], + const overrides = createTranslationMessages({ + ref, + messages: { key: 'value1' }, + }); + const resource = createTranslationResource({ + ref, + translations: { + en: () => + Promise.resolve({ + default: { + key: 'value2', + }, + }), + }, }); - instance.initMessages(options); - expect(useResourcesMock).toHaveBeenCalledWith( - options.messages[0].ref, - options.messages[0].messages, - ); - expect(useLazyResourcesMock).toHaveBeenCalledWith( - options.messages[0].ref, - options.messages[0].lazyMessages, - ); + AppTranslationApiImpl.create({ + supportedLanguages: ['en'], + resources: [overrides, resource], + }); + + expect(addMessagesMock).toHaveBeenCalledWith(overrides); + expect(addLazyResourcesMock).toHaveBeenCalledWith(resource); }); it('should useResources correctly', () => { - const i18nMock = i18next.createInstance() as any; - const useReturnMock = i18nMock.use(); - jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock); - jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock); - - const ref = createTranslationRef({ - id: 'ref-id', - messages: { - key1: 'value1', - }, - resources: { - en: { - key1: 'value2', - }, - }, - }); - - const instance = AppTranslationApiImpl.create({ - supportedLanguages: ['en'], - }); - instance.addResources(ref); - - expect(useReturnMock.addResourceBundle).toHaveBeenCalledWith( - 'en', - 'ref-id', - { key1: 'value2' }, - true, - false, + const addLazyResourcesMock = jest.spyOn( + AppTranslationApiImpl.prototype, + 'addLazyResources', ); - }); - - it('should useLazyResources correctly', () => { - const i18nMock = i18next.createInstance() as any; - const useReturnMock = i18nMock.use(); - - jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock); - jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock); const ref = createTranslationRef({ id: 'ref-id', messages: { key1: 'value1', }, - lazyResources: { - en: () => Promise.resolve({ messages: { key1: 'value2' } }), + translations: { + en: () => Promise.resolve({ default: { key1: 'value2' } }), }, }); const instance = AppTranslationApiImpl.create({ supportedLanguages: ['en'], }); - instance.addLazyResources(ref); - setTimeout(() => { - expect(useReturnMock.addResourceBundle).toHaveBeenCalledWith( - 'en', - 'ref-id', - { key1: 'value2' }, - true, - false, - ); - }); + instance.addResource(ref); + + expect(addLazyResourcesMock).toHaveBeenCalledTimes(1); + expect(addLazyResourcesMock.mock.calls[0][0].id).toBe('ref-id'); }); }); diff --git a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts index 69820e7b67..c55c8bd7e8 100644 --- a/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts +++ b/packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts @@ -16,28 +16,39 @@ import { AppTranslationApi, + TranslationMessages, TranslationRef, + TranslationResource, } from '@backstage/core-plugin-api/alpha'; import i18next, { type i18n } from 'i18next'; import { initReactI18next } from 'react-i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; -import { TranslationMessages } from '../../../alpha'; +// Internal import to avoid code duplication, this will lead to duplication in build output +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalTranslationResource } from '../../../../../core-plugin-api/src/translation/TranslationResource'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalTranslationRef } from '../../../../../core-plugin-api/src/translation/TranslationRef'; + +const DEFAULT_LANGUAGE = 'en'; /** @alpha */ export type ExperimentalI18n = { - supportedLanguages: string[]; fallbackLanguage?: string | string[]; - messages?: Array<{ - ref: TranslationRef; - messages?: Record>; - lazyMessages: Record< - string, - () => Promise<{ messages: TranslationMessages }> - >; - }>; + supportedLanguages?: string[]; + resources?: Array; }; +function removeNulls( + messages: Record, +): Record { + return Object.fromEntries( + Object.entries(messages).filter( + (e): e is [string, string] => e[1] !== null, + ), + ); +} + /** @alpha */ export class AppTranslationApiImpl implements AppTranslationApi { static create(options?: ExperimentalI18n) { @@ -46,8 +57,8 @@ export class AppTranslationApiImpl implements AppTranslationApi { i18n.use(LanguageDetector); i18n.init({ - fallbackLng: options?.fallbackLanguage || 'en', - supportedLngs: options?.supportedLanguages || ['en'], + fallbackLng: options?.fallbackLanguage || DEFAULT_LANGUAGE, + supportedLngs: options?.supportedLanguages || [DEFAULT_LANGUAGE], interpolation: { escapeValue: false, }, @@ -59,69 +70,52 @@ export class AppTranslationApiImpl implements AppTranslationApi { return new AppTranslationApiImpl(i18n, options); } - private readonly cache = new WeakSet(); - private readonly lazyCache = new WeakMap>(); + private readonly cache = new Set(); + private readonly lazyCache = new Map>(); getI18n() { return this.i18n; } initMessages(options?: ExperimentalI18n) { - if (options?.messages?.length) { - options.messages.forEach(appMessage => { - if (appMessage.messages) { - this.addResources(appMessage.ref, appMessage.messages); - } - - if (appMessage.lazyMessages) { - this.addLazyResources(appMessage.ref, appMessage.lazyMessages); - } - }); + for (const resource of options?.resources || []) { + if (resource.$$type === '@backstage/TranslationResource') { + this.addLazyResources(resource); + } else if (resource.$$type === '@backstage/TranslationMessages') { + // Overrides for default messages, created with createTranslationMessages and installed via app + this.addMessages(resource); + } } } - addResourcesByRef>( - translationRef: TranslationRef, - ): void { - this.addResources(translationRef); - this.addLazyResources(translationRef); + addResource(translationRef: TranslationRef): void { + const internalRef = toInternalTranslationRef(translationRef); + const defaultResource = internalRef.getDefaultResource(); + if (defaultResource) { + this.addLazyResources(defaultResource); + } } - addResources>( - translationRef: TranslationRef, - initResources?: Record< - string, - TranslationMessages> - >, - ) { - const resources = initResources || translationRef.getResources(); - if (!resources || this.cache.has(translationRef)) { + addMessages(messages: TranslationMessages) { + if (this.cache.has(messages.id)) { return; } - this.cache.add(translationRef); - Object.entries(resources).forEach(([language, messages]) => { - this.i18n.addResourceBundle( - language, - translationRef.getId(), - messages, - true, - false, - ); - }); + this.cache.add(messages.id); + this.i18n.addResourceBundle( + DEFAULT_LANGUAGE, + messages.id, + removeNulls(messages.messages), + true, + false, + ); } - addLazyResources>( - translationRef: TranslationRef, - initResources?: Record< - string, - () => Promise<{ messages: TranslationMessages }> - >, - ) { - let cache = this.lazyCache.get(translationRef); + addLazyResources(resource: TranslationResource) { + let cache = this.lazyCache.get(resource.id); if (!cache) { cache = new Set(); - this.lazyCache.set(translationRef, cache); + this.lazyCache.set(resource.id, cache); } const { @@ -136,8 +130,8 @@ export class AppTranslationApiImpl implements AppTranslationApi { return; } - const namespace = translationRef.getId(); - const lazyResources = initResources || translationRef.getLazyResources(); + const internalResource = toInternalTranslationResource(resource); + const namespace = internalResource.id; Promise.allSettled((options.supportedLngs || []).map(addLanguage)).then( results => { @@ -160,7 +154,9 @@ export class AppTranslationApiImpl implements AppTranslationApi { loadBackend = reloadResources([language], [namespace]); } - const loadLazyResources = lazyResources?.[language]; + const loadLazyResources = internalResource.resources.find( + entry => entry.language === language, + )?.loader; if (!loadLazyResources) { await loadBackend; diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index 1b68cfd6ca..2eddcfc3ae 100644 --- a/packages/core-app-api/src/app/AppManager.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -260,7 +260,7 @@ describe('Integration Test', () => { expect(screen.getByText('extLink4: ')).toBeInTheDocument(); }); - it('runs success with __experimentalI18n', async () => { + it('runs success with __experimentalTranslations', async () => { const app = new AppManager({ apis: [noOpAnalyticsApi], defaultApis: [], @@ -275,7 +275,7 @@ describe('Integration Test', () => { extRouteRef2: plugin2RouteRef, }); }, - __experimentalI18n: { + __experimentalTranslations: { supportedLanguages: ['en'], }, }); diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 0bd495c03b..bd6a1ba3d0 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -179,7 +179,7 @@ export class AppManager implements BackstageApp { this.bindRoutes = options.bindRoutes; this.apiFactoryRegistry = new ApiFactoryRegistry(); this.appTranslationApi = AppTranslationApiImpl.create( - options.__experimentalI18n, + options.__experimentalTranslations, ); } diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 3d4b9cf8c5..0adb93cfd1 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -27,7 +27,10 @@ import { FeatureFlag, } from '@backstage/core-plugin-api'; import { AppConfig } from '@backstage/config'; -import { TranslationRef } from '@backstage/core-plugin-api/alpha'; +import { + TranslationMessages, + TranslationResource, +} from '@backstage/core-plugin-api/alpha'; /** * Props for the `BootErrorPage` component of {@link AppComponents}. @@ -178,16 +181,6 @@ export type AppRouteBinder = < >, ) => void; -/** - * TODO: To be remove when TranslationMessages in packages/core-app-api/src/app/TranslationResource.ts - * come to be public - * - * @ignore - * */ -type TranslationMessages = T extends TranslationRef - ? Partial - : never; - /** * The options accepted by {@link createSpecializedApp}. * @@ -290,21 +283,11 @@ export type AppOptions = { */ bindRoutes?(context: { bind: AppRouteBinder }): void; - /** - * TODO: Change to ExperimentalI18n type when packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts - * become to public - */ - __experimentalI18n?: { - supportedLanguages: string[]; + // TODO: Change to ExperimentalI18n type when packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts + __experimentalTranslations?: { fallbackLanguage?: string | string[]; - messages?: Array<{ - ref: TranslationRef; - messages?: Record>; - lazyMessages: Record< - string, - () => Promise<{ messages: TranslationMessages }> - >; - }>; + supportedLanguages?: string[]; + resources?: Array; }; }; diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index 715e0d4af7..c40d45f8b4 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -351,7 +351,7 @@ export const WorkaroundNavLink = React.forwardRef< aria-current={ariaCurrent} style={{ ...style, ...(isActive ? activeStyle : undefined) }} className={classnames([ - className, + typeof className !== 'function' ? className : undefined, isActive ? activeClassName : undefined, ])} /> diff --git a/packages/core-plugin-api/alpha-api-report.md b/packages/core-plugin-api/alpha-api-report.md index 57cf4a2cb4..f186c36b7f 100644 --- a/packages/core-plugin-api/alpha-api-report.md +++ b/packages/core-plugin-api/alpha-api-report.md @@ -7,24 +7,64 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { i18n } from 'i18next'; import { ReactNode } from 'react'; +import { TranslationMessages as TranslationMessages_2 } from '@backstage/core-plugin-api/alpha'; +import { TranslationRef as TranslationRef_2 } from '@backstage/core-plugin-api/alpha'; // @alpha (undocumented) export type AppTranslationApi = { getI18n(): i18n; - addResourcesByRef>( - translationRef: TranslationRef, - ): void; + addResource(resource: TranslationRef): void; }; // @alpha (undocumented) export const appTranslationApiRef: ApiRef; -// @alpha (undocumented) -export const createTranslationRef: < - Messages extends Record = {}, +// @alpha +export function createTranslationMessages< + TId extends string, + TMessages extends { + [key in string]: string; + }, + TFull extends boolean, >( - config: TranslationRefConfig, -) => TranslationRef; + options: TranslationMessagesOptions, +): TranslationMessages; + +// @alpha (undocumented) +export function createTranslationRef< + TId extends string, + TMessages extends { + [key in string]: string; + }, + TTranslations extends { + [language in string]: () => Promise<{ + default: { + [key in keyof TMessages]: string | null; + }; + }>; + }, +>( + config: TranslationRefOptions, +): TranslationRef; + +// @alpha (undocumented) +export function createTranslationResource< + TId extends string, + TMessages extends { + [key in string]: string; + }, + TTranslations extends { + [language in string]: () => Promise<{ + default: + | TranslationMessages_2 + | { + [key in keyof TMessages]: string | null; + }; + }>; + }, +>( + options: TranslationResourceOptions, +): TranslationResource; // @alpha export interface PluginOptionsProviderProps { @@ -37,76 +77,115 @@ export interface PluginOptionsProviderProps { // @alpha export const PluginProvider: (props: PluginOptionsProviderProps) => JSX.Element; +// @alpha +export interface TranslationMessages< + TId extends string = string, + TMessages extends { + [key in string]: string; + } = { + [key in string]: string; + }, + TFull extends boolean = boolean, +> { + // (undocumented) + $$type: '@backstage/TranslationMessages'; + full: TFull; + id: TId; + messages: TMessages; +} + +// @alpha +export interface TranslationMessagesOptions< + TId extends string, + TMessages extends { + [key in string]: string; + }, + TFull extends boolean, +> { + // (undocumented) + full?: TFull; + // (undocumented) + messages: false extends TFull + ? { + [key in keyof TMessages]?: string | null; + } + : { + [key in keyof TMessages]: string | null; + }; + // (undocumented) + ref: TranslationRef_2; +} + // @alpha (undocumented) -export type TranslationOptions< - Messages extends Record = Record, -> = Messages; +export interface TranslationOptions {} // @alpha (undocumented) export interface TranslationRef< - Messages extends Record = Record, + TId extends string = string, + TMessages extends { + [key in string]: string; + } = { + [key in string]: string; + }, > { // (undocumented) - getDefaultMessages(): Messages; + $$type: '@backstage/TranslationRef'; // (undocumented) - getId(): string; + id: TId; // (undocumented) - getLazyResources(): - | Record< - string, - () => Promise<{ - messages: Messages; - }> - > - | undefined; - // (undocumented) - getResources(): Record | undefined; + T: TMessages; } // @alpha (undocumented) -export interface TranslationRefConfig< - Messages extends Record, +export interface TranslationRefOptions< + TId extends string, + TMessages extends { + [key in string]: string; + }, + TTranslations extends { + [language in string]: () => Promise<{ + default: { + [key in keyof TMessages]: string | null; + }; + }>; + }, > { // (undocumented) - id: string; + id: TId; // (undocumented) - lazyResources?: Record< - string, - () => Promise<{ - messages: Messages; - }> - >; + messages: TMessages; // (undocumented) - messages: Messages; - // (undocumented) - resources?: Record; + translations?: TTranslations; } // @alpha (undocumented) -export class TranslationRefImpl> - implements TranslationRef -{ +export interface TranslationResource { // (undocumented) - static create>( - config: TranslationRefConfig, - ): TranslationRefImpl; + $$type: '@backstage/TranslationResource'; // (undocumented) - getDefaultMessages(): Messages; + id: TId; +} + +// @alpha (undocumented) +export interface TranslationResourceOptions< + TId extends string, + TMessages extends { + [key in string]: string; + }, + TTranslations extends { + [language in string]: () => Promise<{ + default: + | TranslationMessages_2 + | { + [key in keyof TMessages]: string | null; + }; + }>; + }, +> { // (undocumented) - getId(): string; + ref: TranslationRef_2; // (undocumented) - getLazyResources(): - | Record< - string, - () => Promise<{ - messages: Messages; - }> - > - | undefined; - // (undocumented) - getResources(): Record | undefined; - // (undocumented) - toString(): string; + translations: TTranslations; } // @alpha @@ -116,13 +195,15 @@ export function usePluginOptions< // @alpha (undocumented) export const useTranslationRef: < - Messages extends Record, + TMessages extends { + [x: string]: string; + }, >( - translationRef: TranslationRef, -) => ( - key: Tkey, + translationRef: TranslationRef, +) => ( + key: TKey, options?: TranslationOptions, -) => Messages[Tkey]; +) => TMessages[TKey]; // (No @packageDocumentation comment for this package) ``` diff --git a/packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts index 463aee02bf..44f85c0c89 100644 --- a/packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AppTranslationApi.ts @@ -22,14 +22,12 @@ import { ApiRef, createApiRef } from '@backstage/core-plugin-api'; export type AppTranslationApi = { getI18n(): i18n; - addResourcesByRef>( - translationRef: TranslationRef, - ): void; + addResource(resource: TranslationRef): void; }; /** * @alpha */ export const appTranslationApiRef: ApiRef = createApiRef({ - id: 'core.apptranslation', + id: 'core.translation', }); diff --git a/packages/core-plugin-api/src/translation/TranslationMessages.test.ts b/packages/core-plugin-api/src/translation/TranslationMessages.test.ts new file mode 100644 index 0000000000..5314c8a054 --- /dev/null +++ b/packages/core-plugin-api/src/translation/TranslationMessages.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { createTranslationMessages } from './TranslationMessages'; +import { createTranslationRef } from './TranslationRef'; + +const ref = createTranslationRef({ + id: 'counting', + messages: { + one: 'one', + two: 'two', + three: 'three', + } as const, +}); + +describe('createTranslationMessages', () => { + it('should create a partial message set', () => { + expect( + createTranslationMessages({ + ref, + messages: { + one: 'uno', + }, + }), + ).toEqual({ + $$type: '@backstage/TranslationMessages', + id: 'counting', + full: false, + messages: { + one: 'uno', + }, + }); + }); + + it('should create a full message set', () => { + expect( + createTranslationMessages({ + ref, + full: true, + messages: { + one: 'uno', + two: 'dos', + three: null, + }, + }), + ).toEqual({ + $$type: '@backstage/TranslationMessages', + id: 'counting', + full: true, + messages: { + one: 'uno', + two: 'dos', + three: null, + }, + }); + }); + + it('should require all messages in a full set', () => { + expect( + createTranslationMessages({ + ref, + full: true, + // @ts-expect-error + messages: { + one: 'uno', + two: 'dos', + }, + }), + ).toEqual({ + $$type: '@backstage/TranslationMessages', + id: 'counting', + full: true, + messages: { + one: 'uno', + two: 'dos', + }, + }); + }); +}); diff --git a/packages/core-plugin-api/src/translation/TranslationMessages.ts b/packages/core-plugin-api/src/translation/TranslationMessages.ts new file mode 100644 index 0000000000..0108fb9f18 --- /dev/null +++ b/packages/core-plugin-api/src/translation/TranslationMessages.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { TranslationRef } from '@backstage/core-plugin-api/alpha'; + +/** + * Represents a collection of messages to be provided for a given translation ref. + * + * @alpha + * @remarks + * + * This collection of messages can either be used directly as an override for the + * default messages, or it can be used to provide translations for a language by + * by being referenced by a {@link TranslationResource}. + */ +export interface TranslationMessages< + TId extends string = string, + TMessages extends { [key in string]: string } = { [key in string]: string }, + TFull extends boolean = boolean, +> { + $$type: '@backstage/TranslationMessages'; + /** The ID of the translation ref that these messages are for */ + id: TId; + /** Whether or not these messages override all known messages */ + full: TFull; + /** The messages provided for the given translation ref */ + messages: TMessages; +} + +/** + * Options for {@link createTranslationMessages}. + * + * @alpha + */ +export interface TranslationMessagesOptions< + TId extends string, + TMessages extends { [key in string]: string }, + TFull extends boolean, +> { + ref: TranslationRef; + + full?: TFull; + + messages: false extends TFull + ? { [key in keyof TMessages]?: string | null } + : { [key in keyof TMessages]: string | null }; +} + +/** + * Creates a collection of messages for a given translation ref. + * + * @alpha + */ +export function createTranslationMessages< + TId extends string, + TMessages extends { [key in string]: string }, + TFull extends boolean, +>( + options: TranslationMessagesOptions, +): TranslationMessages { + return { + $$type: '@backstage/TranslationMessages', + id: options.ref.id, + full: Boolean(options.full) as TFull, + messages: options.messages as TMessages, + }; +} diff --git a/packages/core-plugin-api/src/translation/TranslationRef.test.ts b/packages/core-plugin-api/src/translation/TranslationRef.test.ts index 155624a122..b73b521774 100644 --- a/packages/core-plugin-api/src/translation/TranslationRef.test.ts +++ b/packages/core-plugin-api/src/translation/TranslationRef.test.ts @@ -13,48 +13,61 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TranslationRefImpl, createTranslationRef } from './TranslationRef'; +import { + createTranslationRef, + toInternalTranslationRef, +} from './TranslationRef'; +import { toInternalTranslationResource } from './TranslationResource'; describe('TranslationRefImpl', () => { - it('should create a TranslationRef instance', () => { - const config = { - id: 'testId', - messages: { key: 'value' }, - }; - - const translationRef = TranslationRefImpl.create(config); - - expect(translationRef.getId()).toBe('testId'); - expect(translationRef.getDefaultMessages()).toEqual({ key: 'value' }); - }); - it('should create a TranslationRef instance using the factory function', () => { - const config = { - id: 'testId', + const ref = createTranslationRef({ + id: 'test', messages: { key: 'value' }, - }; + }); - const translationRef = createTranslationRef(config); + const internalRef = toInternalTranslationRef(ref); - expect(translationRef.getId()).toBe('testId'); - expect(translationRef.getDefaultMessages()).toEqual({ key: 'value' }); + expect(internalRef.$$type).toBe('@backstage/TranslationRef'); + expect(internalRef.version).toBe('v1'); + expect(internalRef.id).toBe('test'); + expect(internalRef.getDefaultMessages()).toEqual({ key: 'value' }); }); - it('should get lazy resources', async () => { - const config = { - id: 'testId', + it('should be created with lazy translations', async () => { + const ref = createTranslationRef({ + id: 'test', messages: { key: 'value' }, - lazyResources: { - en: () => Promise.resolve({ messages: { key: 'value' } }), + translations: { + de: () => Promise.resolve({ default: { key: 'other-value' } }), }, - }; + }); - const translationRef = TranslationRefImpl.create(config); + const internalRef = toInternalTranslationRef(ref); - const lazyResources = translationRef.getLazyResources(); + expect(internalRef.$$type).toBe('@backstage/TranslationRef'); + expect(internalRef.version).toBe('v1'); + expect(internalRef.id).toBe('test'); + expect(internalRef.getDefaultMessages()).toEqual({ key: 'value' }); - const messages = await lazyResources?.en(); - - expect(messages).toEqual({ messages: { key: 'value' } }); + const internalResource = toInternalTranslationResource( + internalRef.getDefaultResource()!, + ); + expect(internalResource).toEqual({ + $$type: '@backstage/TranslationResource', + version: 'v1', + id: 'test', + resources: [ + { + language: 'de', + loader: expect.any(Function), + }, + ], + }); + await expect(internalResource.resources[0].loader()).resolves.toEqual({ + messages: { + key: 'other-value', + }, + }); }); }); diff --git a/packages/core-plugin-api/src/translation/TranslationRef.ts b/packages/core-plugin-api/src/translation/TranslationRef.ts index d01f7ca261..748473f92d 100644 --- a/packages/core-plugin-api/src/translation/TranslationRef.ts +++ b/packages/core-plugin-api/src/translation/TranslationRef.ts @@ -14,48 +14,131 @@ * limitations under the License. */ -import { TranslationRef, TranslationRefConfig } from './types'; +import { + createTranslationResource, + TranslationResource, +} from './TranslationResource'; /** @alpha */ -export class TranslationRefImpl> - implements TranslationRef -{ - static create>( - config: TranslationRefConfig, - ) { - return new TranslationRefImpl(config); - } +export interface TranslationRef< + TId extends string = string, + TMessages extends { [key in string]: string } = { [key in string]: string }, +> { + $$type: '@backstage/TranslationRef'; - getId() { - return this.config.id; - } + id: TId; - getDefaultMessages(): Messages { - return this.config.messages; - } + T: TMessages; +} - getLazyResources(): - | Record Promise<{ messages: Messages }>> - | undefined { - return this.config.lazyResources; - } +/** @internal */ +type AnyMessages = { [key in string]: string }; - getResources(): Record | undefined { - return this.config.resources; - } +/** @internal */ +export interface InternalTranslationRef< + TId extends string = string, + TMessages extends { [key in string]: string } = { [key in string]: string }, +> extends TranslationRef { + version: 'v1'; - toString() { - return `TranslationRef(${this.getId()})`; - } + getDefaultMessages(): AnyMessages; - private constructor( - private readonly config: TranslationRefConfig, - ) {} + getDefaultResource(): TranslationResource | undefined; } /** @alpha */ -export const createTranslationRef = < - Messages extends Record = {}, +export interface TranslationRefOptions< + TId extends string, + TMessages extends { [key in string]: string }, + TTranslations extends { + [language in string]: () => Promise<{ + default: { [key in keyof TMessages]: string | null }; + }>; + }, +> { + id: TId; + messages: TMessages; + translations?: TTranslations; +} + +/** @internal */ +class TranslationRefImpl< + TId extends string, + TMessages extends { [key in string]: string }, +> implements InternalTranslationRef +{ + #id: TId; + #messages: TMessages; + #resources: TranslationResource | undefined; + + constructor(options: TranslationRefOptions) { + this.#id = options.id; + this.#messages = options.messages; + } + + $$type = '@backstage/TranslationRef' as const; + + version = 'v1' as const; + + get id(): TId { + return this.#id; + } + + get T(): never { + throw new Error('Not implemented'); + } + + getDefaultMessages(): AnyMessages { + return this.#messages; + } + + setDefaultResource(resources: TranslationResource): void { + this.#resources = resources; + } + + getDefaultResource(): TranslationResource | undefined { + return this.#resources; + } + + toString() { + return `TranslationRef{id=${this.id}}`; + } +} + +/** @alpha */ +export function createTranslationRef< + TId extends string, + TMessages extends { [key in string]: string }, + TTranslations extends { + [language in string]: () => Promise<{ + default: { [key in keyof TMessages]: string | null }; + }>; + }, >( - config: TranslationRefConfig, -): TranslationRef => TranslationRefImpl.create(config); + config: TranslationRefOptions, +): TranslationRef { + const ref = new TranslationRefImpl(config); + if (config.translations) { + ref.setDefaultResource( + createTranslationResource({ + ref, + translations: config.translations as any, + }), + ); + } + return ref; +} + +/** @internal */ +export function toInternalTranslationRef( + ref: TranslationRef, +): InternalTranslationRef { + const r = ref as InternalTranslationRef; + if (r.$$type !== '@backstage/TranslationRef') { + throw new Error(`Invalid translation ref, bad type '${r.$$type}'`); + } + if (r.version !== 'v1') { + throw new Error(`Invalid translation ref, bad version '${r.version}'`); + } + return r; +} diff --git a/packages/core-plugin-api/src/translation/TranslationResource.test.ts b/packages/core-plugin-api/src/translation/TranslationResource.test.ts new file mode 100644 index 0000000000..1814311f71 --- /dev/null +++ b/packages/core-plugin-api/src/translation/TranslationResource.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + createTranslationResource, + toInternalTranslationResource, +} from './TranslationResource'; +import { countingTranslationRef } from './__fixtures__/refs'; + +describe('createTranslationResource', () => { + it('should create a simple resource', async () => { + const resource = createTranslationResource({ + ref: countingTranslationRef, + translations: { + sv: () => + Promise.resolve({ + default: { + one: 'ett', + two: 'två', + three: 'tre', + }, + }), + }, + }); + expect(toInternalTranslationResource(resource)).toEqual({ + $$type: '@backstage/TranslationResource', + version: 'v1', + id: 'counting', + resources: [ + { + language: 'sv', + loader: expect.any(Function), + }, + ], + }); + await expect( + toInternalTranslationResource(resource).resources[0].loader(), + ).resolves.toEqual({ + messages: { + one: 'ett', + two: 'två', + three: 'tre', + }, + }); + }); + + it('should create a resource with lazy loaded messages', async () => { + const resource = createTranslationResource({ + ref: countingTranslationRef, + translations: { + de: () => import('./__fixtures__/counting-de'), + sv: () => import('./__fixtures__/counting-sv.json'), + // @ts-expect-error + deBad: () => import('./__fixtures__/fruits-de.json'), + // @ts-expect-error + svBad: () => import('./__fixtures__/fruits-sv'), + }, + }); + expect(toInternalTranslationResource(resource)).toEqual({ + $$type: '@backstage/TranslationResource', + version: 'v1', + id: 'counting', + resources: [ + { + language: 'de', + loader: expect.any(Function), + }, + { + language: 'sv', + loader: expect.any(Function), + }, + { + language: 'deBad', + loader: expect.any(Function), + }, + { + language: 'svBad', + loader: expect.any(Function), + }, + ], + }); + + await expect( + toInternalTranslationResource(resource).resources[0].loader(), + ).resolves.toEqual({ + messages: { + one: 'eins', + two: 'zwei', + three: 'polizei', + }, + }); + + await expect( + toInternalTranslationResource(resource).resources[1].loader(), + ).resolves.toEqual({ + messages: { + one: 'ett', + two: 'två', + three: 'tre', + }, + }); + }); +}); diff --git a/packages/core-plugin-api/src/translation/TranslationResource.ts b/packages/core-plugin-api/src/translation/TranslationResource.ts new file mode 100644 index 0000000000..50d10546cd --- /dev/null +++ b/packages/core-plugin-api/src/translation/TranslationResource.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + TranslationMessages, + TranslationRef, +} from '@backstage/core-plugin-api/alpha'; + +/** @alpha */ +export interface TranslationResource { + $$type: '@backstage/TranslationResource'; + id: TId; +} + +/** @internal */ +export interface InternalTranslationResource + extends TranslationResource { + version: 'v1'; + resources: Array<{ + language: string; + loader(): Promise<{ messages: { [key in string]: string | null } }>; + }>; +} + +/** @internal */ +export function toInternalTranslationResource( + resource: TranslationResource, +): InternalTranslationResource { + const r = resource as InternalTranslationResource; + if (r.$$type !== '@backstage/TranslationResource') { + throw new Error(`Invalid translation resource, bad type '${r.$$type}'`); + } + if (r.version !== 'v1') { + throw new Error(`Invalid translation resource, bad version '${r.version}'`); + } + + return r; +} + +/** @alpha */ +export interface TranslationResourceOptions< + TId extends string, + TMessages extends { [key in string]: string }, + TTranslations extends { + [language in string]: () => Promise<{ + default: + | TranslationMessages + | { [key in keyof TMessages]: string | null }; + }>; + }, +> { + ref: TranslationRef; + + translations: TTranslations; +} + +/** @alpha */ +export function createTranslationResource< + TId extends string, + TMessages extends { [key in string]: string }, + TTranslations extends { + [language in string]: () => Promise<{ + default: + | TranslationMessages + | { [key in keyof TMessages]: string | null }; + }>; + }, +>( + options: TranslationResourceOptions, +): TranslationResource { + return { + $$type: '@backstage/TranslationResource', + version: 'v1', + id: options.ref.id, + resources: Object.entries(options.translations).map( + ([language, loader]) => ({ + language, + loader: () => + loader().then(m => { + const value = m.default; + return { + messages: + value?.$$type === '@backstage/TranslationMessages' + ? value.messages + : value, + }; + }), + }), + ), + } as InternalTranslationResource; +} diff --git a/packages/core-plugin-api/src/translation/__fixtures__/counting-de.ts b/packages/core-plugin-api/src/translation/__fixtures__/counting-de.ts new file mode 100644 index 0000000000..038432141d --- /dev/null +++ b/packages/core-plugin-api/src/translation/__fixtures__/counting-de.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { createTranslationMessages } from '../TranslationMessages'; +import { countingTranslationRef } from './refs'; + +export default createTranslationMessages({ + ref: countingTranslationRef, + messages: { + one: 'eins', + two: 'zwei', + three: 'polizei', + }, +}); diff --git a/packages/core-plugin-api/src/translation/__fixtures__/counting-sv.json b/packages/core-plugin-api/src/translation/__fixtures__/counting-sv.json new file mode 100644 index 0000000000..fdfbde7bd8 --- /dev/null +++ b/packages/core-plugin-api/src/translation/__fixtures__/counting-sv.json @@ -0,0 +1,5 @@ +{ + "one": "ett", + "two": "två", + "three": "tre" +} diff --git a/packages/core-plugin-api/src/translation/__fixtures__/fruits-de.json b/packages/core-plugin-api/src/translation/__fixtures__/fruits-de.json new file mode 100644 index 0000000000..11fb3e78f6 --- /dev/null +++ b/packages/core-plugin-api/src/translation/__fixtures__/fruits-de.json @@ -0,0 +1,4 @@ +{ + "apple": "apfel", + "orange": "apfelsine" +} diff --git a/packages/core-app-api/src/alpha.ts b/packages/core-plugin-api/src/translation/__fixtures__/fruits-sv.ts similarity index 70% rename from packages/core-app-api/src/alpha.ts rename to packages/core-plugin-api/src/translation/__fixtures__/fruits-sv.ts index 288676f509..f6c022abb4 100644 --- a/packages/core-app-api/src/alpha.ts +++ b/packages/core-plugin-api/src/translation/__fixtures__/fruits-sv.ts @@ -13,5 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './apis/implementations/AppTranslationApi'; -export * from './app/TranslationResource'; + +import { createTranslationMessages } from '../TranslationMessages'; +import { fruitsTranslationRef } from './refs'; + +export default createTranslationMessages({ + ref: fruitsTranslationRef, + messages: { + apple: 'äpple', + orange: 'apelsin', + }, +}); diff --git a/packages/core-app-api/src/app/TranslationResource.ts b/packages/core-plugin-api/src/translation/__fixtures__/refs.ts similarity index 57% rename from packages/core-app-api/src/app/TranslationResource.ts rename to packages/core-plugin-api/src/translation/__fixtures__/refs.ts index 65f1c4622c..45fc692c9a 100644 --- a/packages/core-app-api/src/app/TranslationResource.ts +++ b/packages/core-plugin-api/src/translation/__fixtures__/refs.ts @@ -14,21 +14,24 @@ * limitations under the License. */ -import { TranslationRef } from '@backstage/core-plugin-api/alpha'; +import { createTranslationRef } from '../TranslationRef'; -/** @alpha */ -export type TranslationMessages = T extends TranslationRef - ? Partial - : never; +export const countingTranslationRef = createTranslationRef({ + id: 'counting', + messages: { + one: 'one', + two: 'two', + three: 'three', + }, +}); -/** @alpha */ -export function createTranslationResource(options: { - ref: T; - messages?: Record>; - lazyMessages: Record< - string, - () => Promise<{ messages: TranslationMessages }> - >; -}) { - return options; -} +export const fruitsTranslationRef = createTranslationRef({ + id: 'fruits', + messages: { + apple: 'apple', + orange: 'orange', + }, + translations: { + de: () => import('./fruits-de.json'), + }, +}); diff --git a/packages/core-plugin-api/src/translation/index.ts b/packages/core-plugin-api/src/translation/index.ts index 9eaef57206..a03bf4b1f7 100644 --- a/packages/core-plugin-api/src/translation/index.ts +++ b/packages/core-plugin-api/src/translation/index.ts @@ -14,6 +14,22 @@ * limitations under the License. */ -export * from './TranslationRef'; -export * from './types'; -export * from './useTranslationRef'; +export { + type TranslationMessages, + type TranslationMessagesOptions, + createTranslationMessages, +} from './TranslationMessages'; +export { + type TranslationResource, + type TranslationResourceOptions, + createTranslationResource, +} from './TranslationResource'; +export { + type TranslationRef, + type TranslationRefOptions, + createTranslationRef, +} from './TranslationRef'; +export { + type TranslationOptions, + useTranslationRef, +} from './useTranslationRef'; diff --git a/packages/core-plugin-api/src/translation/types.ts b/packages/core-plugin-api/src/translation/types.ts deleted file mode 100644 index 4811c82eb7..0000000000 --- a/packages/core-plugin-api/src/translation/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * 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. - */ - -/** @alpha */ -export interface TranslationRefConfig< - Messages extends Record, -> { - id: string; - messages: Messages; - lazyResources?: Record Promise<{ messages: Messages }>>; - resources?: Record; -} - -/** @alpha */ -export interface TranslationRef< - Messages extends Record = Record, -> { - getId(): string; - - getDefaultMessages(): Messages; - - getResources(): Record | undefined; - - getLazyResources(): - | Record Promise<{ messages: Messages }>> - | undefined; -} - -/** @alpha */ -export type TranslationOptions< - Messages extends Record = Record, -> = Messages; diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.ts b/packages/core-plugin-api/src/translation/useTranslationRef.test.ts index 372d72eb23..54e4440928 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.test.ts +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.ts @@ -51,7 +51,7 @@ describe('useTranslationRef', () => { }; (useApi as jest.Mock).mockReturnValue({ - addResourcesByRef: jest.fn(), + addResource: jest.fn(), }); (useTranslation as jest.Mock).mockReturnValue(i18nMock); diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.ts b/packages/core-plugin-api/src/translation/useTranslationRef.ts index 083bcd1f0e..a11ab9c25b 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.ts +++ b/packages/core-plugin-api/src/translation/useTranslationRef.ts @@ -16,26 +16,34 @@ import { useTranslation } from 'react-i18next'; -import { TranslationOptions, TranslationRef } from './types'; import { useApi } from '../apis'; import { appTranslationApiRef } from '../apis/alpha'; +import { toInternalTranslationRef, TranslationRef } from './TranslationRef'; + +/** @alpha */ +export interface TranslationOptions { + /* no options supported for now */ +} /** @alpha */ export const useTranslationRef = < - Messages extends Record, + TMessages extends { [key in string]: string }, >( - translationRef: TranslationRef, + translationRef: TranslationRef, ) => { - const appTranslationApi = useApi(appTranslationApiRef); + const translationApi = useApi(appTranslationApiRef); - appTranslationApi.addResourcesByRef(translationRef); + const internalRef = toInternalTranslationRef(translationRef); + translationApi.addResource(translationRef); - const { t } = useTranslation(translationRef.getId()); + const { t } = useTranslation(internalRef.id, { + useSuspense: process.env.NODE_ENV !== 'test', + }); - const defaulteMessage = translationRef.getDefaultMessages(); + const defaultMessages = internalRef.getDefaultMessages(); - return ( - key: Tkey, + return ( + key: TKey, options?: TranslationOptions, - ): Messages[Tkey] => t(key as string, defaulteMessage[key], options); + ): TMessages[TKey] => t(key, defaultMessages[key], options); }; diff --git a/plugins/adr/alpha-api-report.md b/plugins/adr/alpha-api-report.md index 1d2b3f313d..7ce9f6f055 100644 --- a/plugins/adr/alpha-api-report.md +++ b/plugins/adr/alpha-api-report.md @@ -6,11 +6,14 @@ import { TranslationRef } from '@backstage/core-plugin-api/alpha'; // @alpha (undocumented) -export const adrTranslationRef: TranslationRef<{ - readonly content_header_title: 'Architecture Decision Records'; - readonly failed_to_fetch: 'Failed to fetch ADRs'; - readonly no_adrs: 'No ADRs found'; -}>; +export const adrTranslationRef: TranslationRef< + 'adr', + { + readonly content_header_title: 'Architecture Decision Records'; + readonly failed_to_fetch: 'Failed to fetch ADRs'; + readonly no_adrs: 'No ADRs found'; + } +>; // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/catalog-import/api-report.md b/plugins/catalog-import/api-report.md index f507967d37..7397bfad1d 100644 --- a/plugins/catalog-import/api-report.md +++ b/plugins/catalog-import/api-report.md @@ -238,7 +238,7 @@ export type PreparePullRequestFormProps< UseFormReturn, 'formState' | 'register' | 'control' | 'setValue' > & { - values: UnpackNestedValue; + values: TFieldValues; }, ) => React_2.ReactNode; }; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx index 079ed57022..f0d84f13a4 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx @@ -18,7 +18,6 @@ import React from 'react'; import { FormProvider, SubmitHandler, - UnpackNestedValue, useForm, UseFormProps, UseFormReturn, @@ -39,7 +38,7 @@ export type PreparePullRequestFormProps< UseFormReturn, 'formState' | 'register' | 'control' | 'setValue' > & { - values: UnpackNestedValue; + values: TFieldValues; }, ) => React.ReactNode; }; diff --git a/plugins/graphiql/api-report.md b/plugins/graphiql/api-report.md index 788551476a..cbecc37006 100644 --- a/plugins/graphiql/api-report.md +++ b/plugins/graphiql/api-report.md @@ -8,6 +8,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { OAuthApi } from '@backstage/core-plugin-api'; @@ -23,6 +24,7 @@ export type EndpointConfig = { headers?: { [name in string]: string; }; + fetchApi?: FetchApi; }; // @public (undocumented) @@ -31,6 +33,7 @@ export type GithubEndpointConfig = { title: string; url?: string; errorApi?: ErrorApi; + fetchApi?: FetchApi; githubAuthApi: OAuthApi; }; diff --git a/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts b/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts index 52dc40fd81..4235d5f7cf 100644 --- a/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts +++ b/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts @@ -15,7 +15,7 @@ */ import { GraphQLBrowseApi, GraphQLEndpoint } from './types'; -import { ErrorApi, OAuthApi } from '@backstage/core-plugin-api'; +import { ErrorApi, FetchApi, OAuthApi } from '@backstage/core-plugin-api'; /** * Helper for generic http endpoints @@ -31,6 +31,10 @@ export type EndpointConfig = { method?: 'POST'; // Defaults to setting Content-Type to application/json headers?: { [name in string]: string }; + /** + * Fetch API to use instead of browser fetch() + */ + fetchApi?: FetchApi; }; /** @public */ @@ -45,6 +49,10 @@ export type GithubEndpointConfig = { * Errors will be posted to the ErrorApi if it is provided. */ errorApi?: ErrorApi; + /** + * Fetch API to use instead of browser fetch() + */ + fetchApi?: FetchApi; /** * GitHub Auth API used to authenticate requests. */ @@ -55,7 +63,7 @@ export type GithubEndpointConfig = { export class GraphQLEndpoints implements GraphQLBrowseApi { // Create a support static create(config: EndpointConfig): GraphQLEndpoint { - const { id, title, url, method = 'POST' } = config; + const { id, title, url, method = 'POST', fetchApi } = config; return { id, title, @@ -66,7 +74,7 @@ export class GraphQLEndpoints implements GraphQLBrowseApi { ...config.headers, ...options.headers, }; - const res = await fetch(await url, { + const res = await (fetchApi?.fetch ?? fetch)(await url, { method, headers, body, @@ -88,6 +96,7 @@ export class GraphQLEndpoints implements GraphQLBrowseApi { title, url = 'https://api.github.com/graphql', errorApi, + fetchApi, githubAuthApi, } = config; type ResponseBody = { @@ -101,7 +110,7 @@ export class GraphQLEndpoints implements GraphQLBrowseApi { let retried = false; const doRequest = async (): Promise => { - const res = await fetch(url, { + const res = await (fetchApi?.fetch ?? fetch)(url, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index aaadbfae0e..6c8953d0a3 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import 'buffer'; +import 'buffer'; +import { resolve as resolvePath } from 'path'; import { errorHandler, getVoidLogger } from '@backstage/backend-common'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { NotFoundError } from '@backstage/errors'; @@ -44,7 +45,6 @@ import { KubernetesProxy, } from './KubernetesProxy'; import fetch from 'cross-fetch'; -import mockFs from 'mock-fs'; import type { Request } from 'express'; @@ -680,15 +680,7 @@ describe('KubernetesProxy', () => { httpsRequest.mockClear(); }); describe('should pass the exact response from Kubernetes using the CA file', () => { - afterEach(() => { - mockFs.restore(); - }); - it('should trust contents of specified caFile', async () => { - mockFs({ - '/path/to/ca.crt': 'MOCKCA', - }); - const apiResponse = { kind: 'APIVersions', versions: ['v1'], @@ -706,7 +698,7 @@ describe('KubernetesProxy', () => { url: 'https://localhost:9999', serviceAccountToken: '', authProvider: 'serviceAccount', - caFile: '/path/to/ca.crt', + caFile: resolvePath(__dirname, '__fixtures__/mock-ca.crt'), }, ] as ClusterDetails[]); @@ -736,7 +728,7 @@ describe('KubernetesProxy', () => { expect(httpsRequest).toHaveBeenCalledTimes(1); const [[{ ca }]] = httpsRequest.mock.calls; - expect(ca).toEqual('MOCKCA'); + expect(ca).toMatch('MOCKCA'); }); }); }); diff --git a/plugins/kubernetes-backend/src/service/__fixtures__/mock-ca.crt b/plugins/kubernetes-backend/src/service/__fixtures__/mock-ca.crt new file mode 100644 index 0000000000..96cc8f4b38 --- /dev/null +++ b/plugins/kubernetes-backend/src/service/__fixtures__/mock-ca.crt @@ -0,0 +1 @@ +MOCKCA diff --git a/plugins/user-settings/alpha-api-report.md b/plugins/user-settings/alpha-api-report.md index 925d387882..4c6ac6ef39 100644 --- a/plugins/user-settings/alpha-api-report.md +++ b/plugins/user-settings/alpha-api-report.md @@ -6,21 +6,24 @@ import { TranslationRef } from '@backstage/core-plugin-api/alpha'; // @alpha (undocumented) -export const userSettingsTranslationRef: TranslationRef<{ - language: string; - change_the_language: string; - theme: string; - theme_light: string; - theme_dark: string; - theme_auto: string; - change_the_theme_mode: string; - select_theme_light: string; - select_theme_dark: string; - select_theme_auto: string; - select_theme_custom: string; - lng: string; - select_lng: string; -}>; +export const userSettingsTranslationRef: TranslationRef< + 'user-settings', + { + readonly language: 'Language'; + readonly change_the_language: 'Change the language'; + readonly theme: 'Theme'; + readonly theme_light: 'Light'; + readonly theme_dark: 'Dark'; + readonly theme_auto: 'Auto'; + readonly change_the_theme_mode: 'Change the theme mode'; + readonly select_theme_light: 'Select light'; + readonly select_theme_dark: 'Select dark'; + readonly select_theme_auto: 'Select Auto Theme'; + readonly select_theme_custom: 'Select {{custom}}'; + readonly lng: '{{language}}'; + readonly select_lng: 'Select language {{language}}'; + } +>; // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx index 48d16a9fde..0e07fa8c59 100644 --- a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx @@ -15,7 +15,6 @@ */ import { AppTheme, appThemeApiRef } from '@backstage/core-plugin-api'; -import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { renderWithEffects, TestApiRegistry, @@ -28,7 +27,6 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; import { UserSettingsThemeToggle } from './UserSettingsThemeToggle'; import { ApiProvider, AppThemeSelector } from '@backstage/core-app-api'; -import { userSettingsTranslationRef } from '../../translation'; const mockTheme: AppTheme = { id: 'light-theme', @@ -41,11 +39,6 @@ const mockTheme: AppTheme = { ), }; -jest.mock('@backstage/core-plugin-api/alpha', () => ({ - ...jest.requireActual('@backstage/core-plugin-api/alpha'), - useTranslationRef: jest.fn(), -})); - const apiRegistry = TestApiRegistry.from([ appThemeApiRef, AppThemeSelector.createWithStorage([mockTheme]), @@ -54,15 +47,6 @@ const apiRegistry = TestApiRegistry.from([ describe('', () => { it('toggles the theme select button', async () => { const themeApi = apiRegistry.get(appThemeApiRef); - // todo: general test provider - const messages: Record = - userSettingsTranslationRef.getDefaultMessages(); - - const useTranslationRefMock = jest - .fn() - .mockReturnValue((key: string) => messages[key]); - - (useTranslationRef as jest.Mock).mockImplementation(useTranslationRefMock); const rendered = await renderWithEffects( wrapInTestApp( diff --git a/plugins/user-settings/src/translation.ts b/plugins/user-settings/src/translation.ts index 920f499082..ffe4174d99 100644 --- a/plugins/user-settings/src/translation.ts +++ b/plugins/user-settings/src/translation.ts @@ -33,5 +33,5 @@ export const userSettingsTranslationRef = createTranslationRef({ select_theme_custom: 'Select {{custom}}', lng: '{{language}}', select_lng: 'Select language {{language}}', - }, + } as const, }); diff --git a/yarn.lock b/yarn.lock index 3bad15a7d1..78d55e945d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21802,9 +21802,9 @@ __metadata: linkType: hard "classnames@npm:*, classnames@npm:^2.2.5, classnames@npm:^2.2.6, classnames@npm:^2.3.1": - version: 2.3.1 - resolution: "classnames@npm:2.3.1" - checksum: 14db8889d56c267a591f08b0834989fe542d47fac659af5a539e110cc4266694e8de86e4e3bbd271157dbd831361310a8293e0167141e80b0f03a0f175c80960 + version: 2.3.2 + resolution: "classnames@npm:2.3.2" + checksum: 2c62199789618d95545c872787137262e741f9db13328e216b093eea91c85ef2bfb152c1f9e63027204e2559a006a92eb74147d46c800a9f96297ae1d9f96f4e languageName: node linkType: hard @@ -37373,11 +37373,11 @@ __metadata: linkType: hard "react-hook-form@npm:^7.12.2, react-hook-form@npm:^7.13.0": - version: 7.32.2 - resolution: "react-hook-form@npm:7.32.2" + version: 7.46.1 + resolution: "react-hook-form@npm:7.46.1" peerDependencies: react: ^16.8.0 || ^17 || ^18 - checksum: abe8ba146c313d206f99a01260457bc72af03b2c73d7bd4be5af11598140ec45ef1ef37bac64b80c4dbc298d6aa21af4caa259a133f305a04ddf583faa18e7d0 + checksum: 9c11ba454ce5b2a16e7499f2ca710e0c0cff227f39689b8e7985902f27f99bfc7a2c49f145ef228528764cf8286bf6101fc8a0f5094ce652eb31cab1684518d1 languageName: node linkType: hard