{core,frontend}-plugin-api: inverse dependency
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
import { analyticsApiRef } from '@backstage/core-plugin-api';
|
||||
import { analyticsApiRef } from '../apis/definitions/AnalyticsApi';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
|
||||
describe('useAnalytics', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useApi } from '../apis/system';
|
||||
import { useAnalyticsContext } from './AnalyticsContext';
|
||||
import { analyticsApiRef, AnalyticsTracker, AnalyticsApi } from '../apis';
|
||||
import { useRef } from 'react';
|
||||
|
||||
@@ -14,8 +14,43 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
type AlertApi,
|
||||
type AlertMessage,
|
||||
alertApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { createApiRef, ApiRef } from '../system';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* Message handled by the {@link AlertApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AlertMessage = {
|
||||
message: string;
|
||||
// Severity will default to success since that is what material ui defaults the value to.
|
||||
severity?: 'success' | 'info' | 'warning' | 'error';
|
||||
display?: 'permanent' | 'transient';
|
||||
};
|
||||
|
||||
/**
|
||||
* The alert API is used to report alerts to the app, and display them to the user.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AlertApi = {
|
||||
/**
|
||||
* Post an alert for handling by the application.
|
||||
*/
|
||||
post(alert: AlertMessage): void;
|
||||
|
||||
/**
|
||||
* Observe alerts posted by other parts of the application.
|
||||
*/
|
||||
alert$(): Observable<AlertMessage>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link AlertApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const alertApiRef: ApiRef<AlertApi> = createApiRef({
|
||||
id: 'core.alert',
|
||||
});
|
||||
|
||||
@@ -14,9 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { AnalyticsContextValue } from '../../analytics/types';
|
||||
import type { AnalyticsImplementationBlueprint } from '../../blueprints/';
|
||||
|
||||
/**
|
||||
* Represents an event worth tracking in an analytics system that could inform
|
||||
|
||||
@@ -14,7 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
type AppLanguageApi,
|
||||
appLanguageApiRef,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/** @public */
|
||||
export type AppLanguageApi = {
|
||||
getAvailableLanguages(): { languages: string[] };
|
||||
|
||||
setLanguage(language?: string): void;
|
||||
|
||||
getLanguage(): { language: string };
|
||||
|
||||
language$(): Observable<{ language: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const appLanguageApiRef: ApiRef<AppLanguageApi> = createApiRef({
|
||||
id: 'core.applanguage',
|
||||
});
|
||||
|
||||
@@ -14,8 +14,74 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
type AppTheme,
|
||||
type AppThemeApi,
|
||||
appThemeApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ReactNode } from 'react';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* Describes a theme provided by the app.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AppTheme = {
|
||||
/**
|
||||
* ID used to remember theme selections.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Title of the theme
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Theme variant
|
||||
*/
|
||||
variant: 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* An Icon for the theme mode setting.
|
||||
*/
|
||||
icon?: React.ReactElement;
|
||||
|
||||
Provider(props: { children: ReactNode }): JSX.Element | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The AppThemeApi gives access to the current app theme, and allows switching
|
||||
* to other options that have been registered as a part of the App.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AppThemeApi = {
|
||||
/**
|
||||
* Get a list of available themes.
|
||||
*/
|
||||
getInstalledThemes(): AppTheme[];
|
||||
|
||||
/**
|
||||
* Observe the currently selected theme. A value of undefined means no specific theme has been selected.
|
||||
*/
|
||||
activeThemeId$(): Observable<string | undefined>;
|
||||
|
||||
/**
|
||||
* Get the current theme ID. Returns undefined if no specific theme is selected.
|
||||
*/
|
||||
getActiveThemeId(): string | undefined;
|
||||
|
||||
/**
|
||||
* Set a specific theme to use in the app, overriding the default theme selection.
|
||||
*
|
||||
* Clear the selection by passing in undefined.
|
||||
*/
|
||||
setActiveThemeId(themeId?: string): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link AppThemeApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const appThemeApiRef: ApiRef<AppThemeApi> = createApiRef({
|
||||
id: 'core.apptheme',
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { createApiRef } from '../system';
|
||||
import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
|
||||
import { ExtensionAttachTo } from '../../wiring/resolveExtensionDefinition';
|
||||
|
||||
|
||||
@@ -13,5 +13,22 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
export { type ConfigApi, configApiRef } from '@backstage/core-plugin-api';
|
||||
/**
|
||||
* The Config API is used to provide a mechanism to access the
|
||||
* runtime configuration of the system.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ConfigApi = Config;
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link ConfigApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const configApiRef: ApiRef<ConfigApi> = createApiRef({
|
||||
id: 'core.config',
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* A handle for an open dialog that can be used to interact with it.
|
||||
|
||||
@@ -13,5 +13,43 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
|
||||
export { type DiscoveryApi, discoveryApiRef } from '@backstage/core-plugin-api';
|
||||
/**
|
||||
* The discovery API is used to provide a mechanism for plugins to
|
||||
* discover the endpoint to use to talk to their backend counterpart.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The purpose of the discovery API is to allow for many different deployment
|
||||
* setups and routing methods through a central configuration, instead
|
||||
* of letting each individual plugin manage that configuration.
|
||||
*
|
||||
* Implementations of the discovery API can be a simple as a URL pattern
|
||||
* using the pluginId, but could also have overrides for individual plugins,
|
||||
* or query a separate discovery service.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type DiscoveryApi = {
|
||||
/**
|
||||
* Returns the HTTP base backend URL for a given plugin, without a trailing slash.
|
||||
*
|
||||
* This method must always be called just before making a request, as opposed to
|
||||
* fetching the URL when constructing an API client. That is to ensure that more
|
||||
* flexible routing patterns can be supported.
|
||||
*
|
||||
* For example, asking for the URL for `auth` may return something
|
||||
* like `https://backstage.example.com/api/auth`
|
||||
*/
|
||||
getBaseUrl(pluginId: string): Promise<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link DiscoveryApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const discoveryApiRef: ApiRef<DiscoveryApi> = createApiRef({
|
||||
id: 'core.discovery',
|
||||
});
|
||||
|
||||
@@ -14,9 +14,78 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
type ErrorApiError,
|
||||
type ErrorApiErrorContext,
|
||||
type ErrorApi,
|
||||
errorApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* Mirrors the JavaScript Error class, for the purpose of
|
||||
* providing documentation and optional fields.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ErrorApiError = {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides additional information about an error that was posted to the application.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ErrorApiErrorContext = {
|
||||
/**
|
||||
* If set to true, this error should not be displayed to the user.
|
||||
*
|
||||
* Hidden errors are typically not displayed in the UI, but the ErrorApi
|
||||
* implementation may still report them to error tracking services
|
||||
* or other utilities that care about all errors.
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The error API is used to report errors to the app, and display them to the user.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Plugins can use this API as a method of displaying errors to the user, but also
|
||||
* to report errors for collection by error reporting services.
|
||||
*
|
||||
* If an error can be displayed inline, e.g. as feedback in a form, that should be
|
||||
* preferred over relying on this API to display the error. The main use of this API
|
||||
* for displaying errors should be for asynchronous errors, such as a failing background process.
|
||||
*
|
||||
* Even if an error is displayed inline, it should still be reported through this API
|
||||
* if it would be useful to collect or log it for debugging purposes, but with
|
||||
* the hidden flag set. For example, an error arising from form field validation
|
||||
* should probably not be reported, while a failed REST call would be useful to report.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ErrorApi = {
|
||||
/**
|
||||
* Post an error for handling by the application.
|
||||
*/
|
||||
post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
|
||||
|
||||
/**
|
||||
* Observe errors posted by other parts of the application.
|
||||
*/
|
||||
error$(): Observable<{
|
||||
error: ErrorApiError;
|
||||
context?: ErrorApiErrorContext;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link ErrorApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const errorApiRef: ApiRef<ErrorApi> = createApiRef({
|
||||
id: 'core.error',
|
||||
});
|
||||
|
||||
@@ -13,11 +13,114 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
/* We want to maintain the same information as an enum, so we disable the redeclaration warning */
|
||||
/* eslint-disable @typescript-eslint/no-redeclare */
|
||||
|
||||
export {
|
||||
type FeatureFlag,
|
||||
type FeatureFlagState,
|
||||
type FeatureFlagsSaveOptions,
|
||||
type FeatureFlagsApi,
|
||||
featureFlagsApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* Feature flag descriptor.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type FeatureFlag = {
|
||||
name: string;
|
||||
pluginId: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum representing the state of a feature flag (inactive/active).
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const FeatureFlagState = {
|
||||
/**
|
||||
* Feature flag inactive (disabled).
|
||||
*/
|
||||
None: 0,
|
||||
/**
|
||||
* Feature flag active (enabled).
|
||||
*/
|
||||
Active: 1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type FeatureFlagState =
|
||||
(typeof FeatureFlagState)[keyof typeof FeatureFlagState];
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export namespace FeatureFlagState {
|
||||
export type None = typeof FeatureFlagState.None;
|
||||
export type Active = typeof FeatureFlagState.Active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options to use when saving feature flags.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type FeatureFlagsSaveOptions = {
|
||||
/**
|
||||
* The new feature flag states to save.
|
||||
*/
|
||||
states: Record<string, FeatureFlagState>;
|
||||
|
||||
/**
|
||||
* Whether the saves states should be merged into the existing ones, or replace them.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
merge?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The feature flags API is used to toggle functionality to users across plugins and Backstage.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Plugins can use this API to register feature flags that they have available
|
||||
* for users to enable/disable, and this API will centralize the current user's
|
||||
* state of which feature flags they would like to enable.
|
||||
*
|
||||
* This is ideal for Backstage plugins, as well as your own App, to trial incomplete
|
||||
* or unstable upcoming features. Although there will be a common interface for users
|
||||
* to enable and disable feature flags, this API acts as another way to enable/disable.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface FeatureFlagsApi {
|
||||
/**
|
||||
* Registers a new feature flag. Once a feature flag has been registered it
|
||||
* can be toggled by users, and read back to enable or disable features.
|
||||
*/
|
||||
registerFlag(flag: FeatureFlag): void;
|
||||
|
||||
/**
|
||||
* Get a list of all registered flags.
|
||||
*/
|
||||
getRegisteredFlags(): FeatureFlag[];
|
||||
|
||||
/**
|
||||
* Whether the feature flag with the given name is currently activated for the user.
|
||||
*/
|
||||
isActive(name: string): boolean;
|
||||
|
||||
/**
|
||||
* Save the user's choice of feature flag states.
|
||||
*/
|
||||
save(options: FeatureFlagsSaveOptions): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link FeatureFlagsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi> = createApiRef({
|
||||
id: 'core.featureflags',
|
||||
});
|
||||
|
||||
@@ -14,4 +14,38 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { type FetchApi, fetchApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* A wrapper for the fetch API, that has additional behaviors such as the
|
||||
* ability to automatically inject auth information where necessary.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type FetchApi = {
|
||||
/**
|
||||
* The `fetch` implementation.
|
||||
*/
|
||||
fetch: typeof fetch;
|
||||
};
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link FetchApi}.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This is a wrapper for the fetch API, that has additional behaviors such as
|
||||
* the ability to automatically inject auth information where necessary.
|
||||
*
|
||||
* Note that the default behavior of this API (unless overridden by your org),
|
||||
* is to require that the user is already signed in so that it has auth
|
||||
* information to inject. Therefore, using the default implementation of this
|
||||
* utility API e.g. on the `SignInPage` or similar, would cause issues. In
|
||||
* special circumstances like those, you can use the regular system `fetch`
|
||||
* instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const fetchApiRef: ApiRef<FetchApi> = createApiRef({
|
||||
id: 'core.fetch',
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { createApiRef } from '../system';
|
||||
import { IconComponent } from '../../icons';
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,5 +13,44 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { BackstageUserIdentity, ProfileInfo } from './auth';
|
||||
|
||||
export { type IdentityApi, identityApiRef } from '@backstage/core-plugin-api';
|
||||
/**
|
||||
* The Identity API used to identify and get information about the signed in user.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type IdentityApi = {
|
||||
/**
|
||||
* The profile of the signed in user.
|
||||
*/
|
||||
getProfileInfo(): Promise<ProfileInfo>;
|
||||
|
||||
/**
|
||||
* User identity information within Backstage.
|
||||
*/
|
||||
getBackstageIdentity(): Promise<BackstageUserIdentity>;
|
||||
|
||||
/**
|
||||
* Provides credentials in the form of a token which proves the identity of the signed in user.
|
||||
*
|
||||
* The token will be undefined if the signed in user does not have a verified
|
||||
* identity, such as a demo user or mocked user for e2e tests.
|
||||
*/
|
||||
getCredentials(): Promise<{ token?: string }>;
|
||||
|
||||
/**
|
||||
* Sign out the current user
|
||||
*/
|
||||
signOut(): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link IdentityApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const identityApiRef: ApiRef<IdentityApi> = createApiRef({
|
||||
id: 'core.identity',
|
||||
});
|
||||
|
||||
@@ -14,10 +14,118 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
type OAuthRequesterOptions,
|
||||
type OAuthRequester,
|
||||
type PendingOAuthRequest,
|
||||
type OAuthRequestApi,
|
||||
oauthRequestApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { AuthProviderInfo } from './auth';
|
||||
|
||||
/**
|
||||
* Describes how to handle auth requests. Both how to show them to the user, and what to do when
|
||||
* the user accesses the auth request.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type OAuthRequesterOptions<TOAuthResponse> = {
|
||||
/**
|
||||
* Information about the auth provider, which will be forwarded to auth requests.
|
||||
*/
|
||||
provider: AuthProviderInfo;
|
||||
|
||||
/**
|
||||
* Implementation of the auth flow, which will be called synchronously when
|
||||
* trigger() is called on an auth requests.
|
||||
*/
|
||||
onAuthRequest(scopes: Set<string>): Promise<TOAuthResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function used to trigger new auth requests for a set of scopes.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The returned promise will resolve to the same value returned by the onAuthRequest in the
|
||||
* {@link OAuthRequesterOptions}. Or rejected, if the request is rejected.
|
||||
*
|
||||
* This function can be called multiple times before the promise resolves. All calls
|
||||
* will be merged into one request, and the scopes forwarded to the onAuthRequest will be the
|
||||
* union of all requested scopes.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type OAuthRequester<TAuthResponse> = (
|
||||
scopes: Set<string>,
|
||||
) => Promise<TAuthResponse>;
|
||||
|
||||
/**
|
||||
* An pending auth request for a single auth provider. The request will remain in this pending
|
||||
* state until either reject() or trigger() is called.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Any new requests for the same provider are merged into the existing pending request, meaning
|
||||
* there will only ever be a single pending request for a given provider.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PendingOAuthRequest = {
|
||||
/**
|
||||
* Information about the auth provider, as given in the AuthRequesterOptions
|
||||
*/
|
||||
provider: AuthProviderInfo;
|
||||
|
||||
/**
|
||||
* Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError".
|
||||
*/
|
||||
reject(): void;
|
||||
|
||||
/**
|
||||
* Trigger the auth request to continue the auth flow, by for example showing a popup.
|
||||
*
|
||||
* Synchronously calls onAuthRequest with all scope currently in the request.
|
||||
*/
|
||||
trigger(): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides helpers for implemented OAuth login flows within Backstage.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type OAuthRequestApi = {
|
||||
/**
|
||||
* A utility for showing login popups or similar things, and merging together multiple requests for
|
||||
* different scopes into one request that includes all scopes.
|
||||
*
|
||||
* The passed in options provide information about the login provider, and how to handle auth requests.
|
||||
*
|
||||
* The returned AuthRequester function is used to request login with new scopes. These requests
|
||||
* are merged together and forwarded to the auth handler, as soon as a consumer of auth requests
|
||||
* triggers an auth flow.
|
||||
*
|
||||
* See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info.
|
||||
*/
|
||||
createAuthRequester<OAuthResponse>(
|
||||
options: OAuthRequesterOptions<OAuthResponse>,
|
||||
): OAuthRequester<OAuthResponse>;
|
||||
|
||||
/**
|
||||
* Observers pending auth requests. The returned observable will emit all
|
||||
* current active auth request, at most one for each created auth requester.
|
||||
*
|
||||
* Each request has its own info about the login provider, forwarded from the auth requester options.
|
||||
*
|
||||
* Depending on user interaction, the request should either be rejected, or used to trigger the auth handler.
|
||||
* If the request is rejected, all pending AuthRequester calls will fail with a "RejectedError".
|
||||
* If a auth is triggered, and the auth handler resolves successfully, then all currently pending
|
||||
* AuthRequester calls will resolve to the value returned by the onAuthRequest call.
|
||||
*/
|
||||
authRequest$(): Observable<PendingOAuthRequest[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link OAuthRequestApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const oauthRequestApiRef: ApiRef<OAuthRequestApi> = createApiRef({
|
||||
id: 'core.oauthrequest',
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SubRouteRef,
|
||||
ExternalRouteRef,
|
||||
} from '../../routing';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* TS magic for handling route parameters.
|
||||
|
||||
@@ -14,8 +14,97 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
type StorageValueSnapshot,
|
||||
type StorageApi,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { JsonValue, Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* A snapshot in time of the current known value of a storage key.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type StorageValueSnapshot<TValue extends JsonValue> =
|
||||
| {
|
||||
key: string;
|
||||
presence: 'unknown' | 'absent';
|
||||
value?: undefined;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
presence: 'present';
|
||||
value: TValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides a key-value persistence API.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface StorageApi {
|
||||
/**
|
||||
* Create a bucket to store data in.
|
||||
*
|
||||
* @param name - Namespace for the storage to be stored under,
|
||||
* will inherit previous namespaces too
|
||||
*/
|
||||
forBucket(name: string): StorageApi;
|
||||
|
||||
/**
|
||||
* Remove persistent data.
|
||||
*
|
||||
* @param key - Unique key associated with the data.
|
||||
*/
|
||||
remove(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Save persistent data, and emit messages to anyone that is using
|
||||
* {@link StorageApi.observe$} for this key.
|
||||
*
|
||||
* @param key - Unique key associated with the data.
|
||||
* @param data - The data to be stored under the key.
|
||||
*/
|
||||
set<T extends JsonValue>(key: string, data: T): Promise<void>;
|
||||
|
||||
/**
|
||||
* Observe the value over time for a particular key in the current bucket.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The observable will only emit values when the value changes in the underlying
|
||||
* storage, although multiple values with the same shape may be emitted in a row.
|
||||
*
|
||||
* If a {@link StorageApi.snapshot} of a key is retrieved and the presence is
|
||||
* `'unknown'`, then you are guaranteed to receive a snapshot with a known
|
||||
* presence, as long as you observe the key within the same tick.
|
||||
*
|
||||
* Since the emitted values are shared across all subscribers, it is important
|
||||
* not to mutate the returned values. The values may be frozen as a precaution.
|
||||
*
|
||||
* @param key - Unique key associated with the data
|
||||
*/
|
||||
observe$<T extends JsonValue>(
|
||||
key: string,
|
||||
): Observable<StorageValueSnapshot<T>>;
|
||||
|
||||
/**
|
||||
* Returns an immediate snapshot value for the given key, if possible.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Combine with {@link StorageApi.observe$} to get notified of value changes.
|
||||
*
|
||||
* Note that this method is synchronous, and some underlying storages may be
|
||||
* unable to retrieve a value using this method - the result may or may not
|
||||
* consistently have a presence of 'unknown'. Use {@link StorageApi.observe$}
|
||||
* to be sure to receive an actual value eventually.
|
||||
*/
|
||||
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link StorageApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const storageApiRef: ApiRef<StorageApi> = createApiRef({
|
||||
id: 'core.storage',
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { SwappableComponentRef } from '../../components';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* API for looking up components based on component refs.
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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 { JSX } from 'react';
|
||||
import { TranslationFunction } from './TranslationApi';
|
||||
|
||||
// This is a weak assertion, don't reuse unless you know the drawbacks
|
||||
function expectType<T>(value: T) {
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('TranslationFunction', () => {
|
||||
it('should infer plurals', () => {
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
key_one: 'one';
|
||||
key_other: 'other';
|
||||
thingCount_one: '{{count}} thing';
|
||||
thingCount_other: '{{count}} things';
|
||||
foo: 'foo';
|
||||
}>;
|
||||
expect(f).toBeDefined();
|
||||
|
||||
expectType<string>(f('foo'));
|
||||
// @ts-expect-error
|
||||
f('foo', { count: 1 });
|
||||
|
||||
expectType<string>(f('key', { count: 1 }));
|
||||
// @ts-expect-error
|
||||
f('key');
|
||||
// @ts-expect-error
|
||||
f('key', { notCount: 1 });
|
||||
// @ts-expect-error
|
||||
f('key_one');
|
||||
// @ts-expect-error
|
||||
f('key_one', { count: 1 });
|
||||
// @ts-expect-error
|
||||
f('key_other');
|
||||
// @ts-expect-error
|
||||
f('key_other', { count: 6 });
|
||||
|
||||
expectType<string>(f('thingCount', { count: 1 }));
|
||||
// @ts-expect-error
|
||||
f('thingCount');
|
||||
// @ts-expect-error
|
||||
f('thingCount', { notCount: 1 });
|
||||
// @ts-expect-error
|
||||
f('thingCount_one');
|
||||
// @ts-expect-error
|
||||
f('thingCount_one', { count: 1 });
|
||||
// @ts-expect-error
|
||||
f('thingCount_other');
|
||||
// @ts-expect-error
|
||||
f('thingCount_other', { count: 6 });
|
||||
|
||||
expectType<'one' | 'other'>(f('key', { count: 6 }));
|
||||
// @ts-expect-error
|
||||
expectType<'one'>(f('key', { count: 6 }));
|
||||
});
|
||||
|
||||
it('should infer interpolation params', () => {
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
none: '=';
|
||||
simple: '= {{bar}}';
|
||||
multiple: '= {{bar }} {{ baz}}';
|
||||
deep: '= {{x.y}} {{ x.z }} {{ a.b.c }}';
|
||||
}>;
|
||||
expect(f).toBeDefined();
|
||||
|
||||
// @ts-expect-error
|
||||
f('none', { replace: { unknown: 1 } });
|
||||
expectType<string>(f('simple', { bar: '' }));
|
||||
// @ts-expect-error
|
||||
expectType<string>(f('simple', { bar: <div /> }));
|
||||
expectType<JSX.Element>(f('simple', { bar: <div /> }));
|
||||
expectType<JSX.Element>(f('simple', { replace: { bar: <div /> } }));
|
||||
// @ts-expect-error
|
||||
f('simple');
|
||||
// @ts-expect-error
|
||||
f('simple', {});
|
||||
// @ts-expect-error
|
||||
f('simple', { replace: {} });
|
||||
// @ts-expect-error
|
||||
f('simple', { replace: { wrong: '' } });
|
||||
expectType<string>(f('multiple', { bar: '', baz: '' }));
|
||||
expectType<JSX.Element>(f('multiple', { bar: <div />, baz: '' }));
|
||||
expectType<JSX.Element>(f('multiple', { bar: '', baz: <div /> }));
|
||||
expectType<JSX.Element>(f('multiple', { bar: <div />, baz: <div /> }));
|
||||
// @ts-expect-error
|
||||
f('multiple', { bar: '' });
|
||||
// @ts-expect-error
|
||||
f('multiple', { baz: '' });
|
||||
// @ts-expect-error
|
||||
f('multiple');
|
||||
// @ts-expect-error
|
||||
f('multiple', {});
|
||||
// @ts-expect-error
|
||||
f('multiple', { replace: {} });
|
||||
expectType<string>(
|
||||
f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: '' } } } }),
|
||||
);
|
||||
expectType<JSX.Element>(
|
||||
f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: <div /> } } } }),
|
||||
);
|
||||
// @ts-expect-error
|
||||
f('deep');
|
||||
// @ts-expect-error
|
||||
f('deep', {});
|
||||
// @ts-expect-error
|
||||
f('deep', { replace: {} });
|
||||
// @ts-expect-error
|
||||
f('deep', { x: { y: '', z: '' }, a: { b: '' } });
|
||||
// @ts-expect-error
|
||||
f('deep', { x: { y: '', z: '' } });
|
||||
// @ts-expect-error
|
||||
f('deep', { replace: { a: { b: { c: '' } } } });
|
||||
});
|
||||
|
||||
it('should infer interpolation params with count', () => {
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
simple_one: '= {{bar}}';
|
||||
simple_other: '= {{bar}}';
|
||||
multiple_one: '= {{ bar}} {{baz }}';
|
||||
multiple_other: '= {{bar }} {{ baz}}';
|
||||
deep_one: '= {{ x.y }}';
|
||||
deep_other: '= {{ x.z }} {{ a.b.c }}';
|
||||
}>;
|
||||
expect(f).toBeDefined();
|
||||
|
||||
f('simple', { count: 1, bar: '' });
|
||||
// @ts-expect-error
|
||||
f('simple', { bar: '' });
|
||||
// @ts-expect-error
|
||||
f('simple', { count: 1, replace: {} });
|
||||
// @ts-expect-error
|
||||
f('simple', { replace: {} });
|
||||
// @ts-expect-error
|
||||
f('simple', { replace: { wrong: '' } });
|
||||
f('multiple', { count: 2, replace: { bar: '', baz: '' } });
|
||||
// @ts-expect-error
|
||||
f('multiple', { count: 2, replace: {} });
|
||||
// @ts-expect-error
|
||||
f('multiple', { replace: { bar: '', baz: '' } });
|
||||
// @ts-expect-error
|
||||
f('multiple', { replace: { baz: '' } });
|
||||
// @ts-expect-error
|
||||
f('multiple', { replace: {} });
|
||||
// @ts-expect-error
|
||||
f('multiple', {});
|
||||
f('deep', {
|
||||
count: 1,
|
||||
replace: { x: { y: '', z: '' }, a: { b: { c: '' } } },
|
||||
});
|
||||
// @ts-expect-error
|
||||
f('deep', { count: 1 });
|
||||
// @ts-expect-error
|
||||
f('deep', { x: { y: '', z: '' }, a: { b: { c: '' } } });
|
||||
// @ts-expect-error
|
||||
f('deep', { replace: {} });
|
||||
// @ts-expect-error
|
||||
f('deep', { x: { y: '', z: '' }, a: { b: '' } });
|
||||
// @ts-expect-error
|
||||
f('deep', { x: { y: '', z: '' } });
|
||||
// @ts-expect-error
|
||||
f('deep', { replace: { a: { b: { c: '' } } } });
|
||||
});
|
||||
|
||||
it('should support formatting', () => {
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
none: '{{x}}';
|
||||
number: '{{x, number}}';
|
||||
numberOptions: '{{x, number(minimumFractionDigits: 2)}}';
|
||||
currency: '{{x, currency}}';
|
||||
datetime: '{{x, dateTime}}';
|
||||
relativeTimeOptions: '{{x, relativeTime(quarter)}}';
|
||||
list: '{{x, list}}';
|
||||
}>;
|
||||
expect(f).toBeDefined();
|
||||
|
||||
f('none', { replace: { x: 'x' } });
|
||||
f('number', { x: 1 });
|
||||
f('number', {
|
||||
replace: { x: 1 },
|
||||
formatParams: { x: { minimumFractionDigits: 2 } },
|
||||
});
|
||||
f('numberOptions', { x: 1 });
|
||||
f('currency', { replace: { x: 1 } });
|
||||
f('datetime', { x: new Date() });
|
||||
f('relativeTimeOptions', { replace: { x: 1 } });
|
||||
f('relativeTimeOptions', {
|
||||
replace: { x: 1 },
|
||||
formatParams: { x: { style: 'short' } },
|
||||
});
|
||||
f('list', { replace: { x: ['a', 'b', 'c'] } });
|
||||
// @ts-expect-error
|
||||
f('none', { x: 1 });
|
||||
// @ts-expect-error
|
||||
f('number', { replace: { x: '1' } });
|
||||
// @ts-expect-error
|
||||
f('numberOptions', { x: '1' });
|
||||
// @ts-expect-error
|
||||
f('currency', { x: '1' });
|
||||
// @ts-expect-error
|
||||
f('datetime', { replace: { x: '1' } });
|
||||
// @ts-expect-error
|
||||
f('relativeTimeOptions', { x: '1' });
|
||||
f('relativeTimeOptions', {
|
||||
replace: { x: 1 },
|
||||
// @ts-expect-error
|
||||
formatParams: { x: { minimumFractionDigits: 2 } },
|
||||
});
|
||||
// @ts-expect-error
|
||||
f('list', { x: [1, 2, 3] });
|
||||
});
|
||||
|
||||
it('should support nesting', () => {
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
simple: '$t(foo)';
|
||||
nested: '$t(bar)';
|
||||
nestedCount: '$t(qux)';
|
||||
deep: '$t(baz) $t(qux)';
|
||||
foo: 'foo';
|
||||
bar: '{{ bar }}';
|
||||
baz: '$t(bar) {{ baz }}';
|
||||
qux_one: '{{ qux1 }}';
|
||||
qux_other: '{{ qux2 }}';
|
||||
}>;
|
||||
expect(f).toBeDefined();
|
||||
|
||||
f('simple');
|
||||
f('nested', { bar: 'bar' });
|
||||
f('nestedCount', { count: 1, replace: { qux1: 'qux', qux2: 'qux' } });
|
||||
f('deep', {
|
||||
count: 1,
|
||||
replace: { bar: 'bar', baz: 'baz', qux1: 'qux', qux2: 'qux' },
|
||||
});
|
||||
// @ts-expect-error
|
||||
f('deep', { count: 1, baz: 'baz', qux1: 'qux', qux2: 'qux' });
|
||||
// @ts-expect-error
|
||||
f('deep', { count: 1, replace: { bar: 'bar', qux1: 'qux', qux2: 'qux' } });
|
||||
// @ts-expect-error
|
||||
f('deep', { count: 1, bar: 'bar', baz: 'baz', qux2: 'qux' });
|
||||
// @ts-expect-error
|
||||
f('deep', { count: 1, bar: 'bar', baz: 'baz', qux1: 'qux' });
|
||||
// @ts-expect-error
|
||||
f('deep', {
|
||||
replace: { bar: 'bar', baz: 'baz', qux1: 'qux', qux2: 'qux' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should limit nesting depth', () => {
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
a: '$t(b) {{a}}';
|
||||
b: '$t(c) {{b}}';
|
||||
c: '$t(d) {{c}}';
|
||||
d: '$t(e) {{d}}';
|
||||
e: '$t(f) {{e}}';
|
||||
}>;
|
||||
expect(f).toBeDefined();
|
||||
|
||||
f('a', { replace: { a: '', b: '', c: '', d: '' } });
|
||||
// @ts-expect-error
|
||||
f('a', { a: '', b: '', c: '' });
|
||||
// @ts-expect-error
|
||||
f('a', { replace: { a: '', b: '', c: '', d: '', e: '' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* 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 { ApiRef, createApiRef } from '../system';
|
||||
import { Expand, ExpandRecursive, Observable } from '@backstage/types';
|
||||
import { TranslationRef } from '../../translation';
|
||||
import { JSX } from 'react';
|
||||
|
||||
/**
|
||||
* Base translation options.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
interface BaseOptions {
|
||||
interpolation?: {
|
||||
/** Whether to HTML escape provided values, defaults to false */
|
||||
escapeValue?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All pluralization suffixes supported by i18next
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type TranslationPlural = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
|
||||
|
||||
/**
|
||||
* A mapping of i18n formatting types to their corresponding types and options.
|
||||
* @ignore
|
||||
*/
|
||||
type I18nextFormatMap = {
|
||||
number: {
|
||||
type: number;
|
||||
options: Intl.NumberFormatOptions;
|
||||
};
|
||||
currency: {
|
||||
type: number;
|
||||
options: Intl.NumberFormatOptions;
|
||||
};
|
||||
datetime: {
|
||||
type: Date;
|
||||
options: Intl.DateTimeFormatOptions;
|
||||
};
|
||||
relativetime: {
|
||||
type: number;
|
||||
options: {
|
||||
range?: Intl.RelativeTimeFormatUnit;
|
||||
} & Intl.RelativeTimeFormatOptions;
|
||||
};
|
||||
list: {
|
||||
type: string[];
|
||||
options: Intl.ListFormatOptions;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all pluralized keys from the message map.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* { foo: 'foo', bar_one: 'bar', bar_other: 'bars' } -> 'bar'
|
||||
* ```
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type PluralKeys<TMessages extends { [key in string]: string }> = {
|
||||
[Key in keyof TMessages]: Key extends `${infer K}_${TranslationPlural}`
|
||||
? K
|
||||
: never;
|
||||
}[keyof TMessages];
|
||||
|
||||
/**
|
||||
* Collapses a message map into normalized keys with union values.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* { foo_one: 'foo', foo_other: 'foos' } -> { foo: 'foo' | 'foos' }
|
||||
* ```
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type CollapsedMessages<TMessages extends { [key in string]: string }> = {
|
||||
[key in keyof TMessages as key extends `${infer K}_${TranslationPlural}`
|
||||
? K
|
||||
: key]: TMessages[key];
|
||||
};
|
||||
|
||||
/**
|
||||
* Trim away whitespace
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type Trim<T> = T extends ` ${infer U}`
|
||||
? Trim<U>
|
||||
: T extends `${infer U} `
|
||||
? Trim<U>
|
||||
: T;
|
||||
|
||||
/**
|
||||
* Extracts the key and format from a replacement string.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* 'foo, number' -> { foo: number }, 'foo' -> { foo: undefined }
|
||||
* ```
|
||||
*/
|
||||
type ExtractFormat<Replacement extends string> =
|
||||
Replacement extends `${infer Key},${infer FullFormat}`
|
||||
? {
|
||||
[key in Trim<Key>]: Lowercase<
|
||||
Trim<
|
||||
FullFormat extends `${infer Format}(${string})${string}`
|
||||
? Format
|
||||
: FullFormat
|
||||
>
|
||||
>;
|
||||
}
|
||||
: { [key in Trim<Replacement>]: undefined };
|
||||
|
||||
/**
|
||||
* Expand the keys in a flat map to nested objects.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* { 'a.b': 'foo', 'a.c': 'bar' } -> { a: { b: 'foo', c: 'bar' }
|
||||
* ```
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type ExpandKeys<TMap extends {}> = {
|
||||
[Key in keyof TMap as Key extends `${infer Prefix}.${string}`
|
||||
? Prefix
|
||||
: Key]: Key extends `${string}.${infer Rest}`
|
||||
? ExpandKeys<{ [key in Rest]: TMap[Key] }>
|
||||
: TMap[Key];
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all option keys and their format from a message string.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* 'foo {{bar}} {{baz, number}}' -> { 'bar': undefined, 'baz': 'number' }
|
||||
* ```
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type ReplaceFormatsFromMessage<TMessage> =
|
||||
TMessage extends `${string}{{${infer Replacement}}}${infer Tail}` // no formatting, e.g. {{foo}}
|
||||
? ExpandKeys<ExtractFormat<Replacement>> & ReplaceFormatsFromMessage<Tail>
|
||||
: {};
|
||||
|
||||
/**
|
||||
* Generates the replace options structure
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type ReplaceOptionsFromFormats<TFormats extends {}, TValueType> = {
|
||||
[Key in keyof TFormats]: TFormats[Key] extends keyof I18nextFormatMap
|
||||
? I18nextFormatMap[TFormats[Key]]['type']
|
||||
: TFormats[Key] extends {}
|
||||
? Expand<ReplaceOptionsFromFormats<TFormats[Key], TValueType>>
|
||||
: TValueType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the formatParams options structure
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type ReplaceFormatParamsFromFormats<TFormats extends {}> = {
|
||||
[Key in keyof TFormats]?: TFormats[Key] extends keyof I18nextFormatMap
|
||||
? I18nextFormatMap[TFormats[Key]]['options']
|
||||
: TFormats[Key] extends {}
|
||||
? Expand<ReplaceFormatParamsFromFormats<TFormats[Key]>>
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all nesting keys from a message string.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* 'foo $t(bar) $t(baz)' -> 'bar' | 'baz'
|
||||
* ```
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type NestingKeysFromMessage<TMessage extends string> =
|
||||
TMessage extends `${string}$t(${infer Key})${infer Tail}` // nesting options are not supported
|
||||
? Trim<Key> | NestingKeysFromMessage<Tail>
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Find all referenced keys, given a starting key and the full set of messages.
|
||||
*
|
||||
* This will only discover keys up to 3 levels deep.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* <'x', { x: '$t(y) $t(z)', y: 'y', z: '$t(w)', w: 'w', foo: 'foo' }> -> 'x' | 'y' | 'z' | 'w'
|
||||
* ```
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type NestedMessageKeys<
|
||||
TKey extends keyof TMessages,
|
||||
TMessages extends { [key in string]: string },
|
||||
> =
|
||||
| TKey
|
||||
| NestedMessageKeys2<NestingKeysFromMessage<TMessages[TKey]>, TMessages>;
|
||||
// Can't recursively reference ourself, so instead we got this beauty
|
||||
type NestedMessageKeys2<
|
||||
TKey extends keyof TMessages,
|
||||
TMessages extends { [key in string]: string },
|
||||
> =
|
||||
| TKey
|
||||
| NestedMessageKeys3<NestingKeysFromMessage<TMessages[TKey]>, TMessages>;
|
||||
// Only support 3 levels of nesting
|
||||
type NestedMessageKeys3<
|
||||
TKey extends keyof TMessages,
|
||||
TMessages extends { [key in string]: string },
|
||||
> = TKey | NestingKeysFromMessage<TMessages[TKey]>;
|
||||
|
||||
/**
|
||||
* Converts a union type to an intersection type.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* { foo: 'foo' } | { bar: 'bar' } -> { foo: 'foo' } & { bar: 'bar' }
|
||||
* ```
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
||||
k: infer I,
|
||||
) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Collects different types of options into a single object
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type CollectOptions<
|
||||
TCount extends { count?: number },
|
||||
TFormats extends {},
|
||||
TValueType,
|
||||
> = TCount &
|
||||
// count is special, omit it from the replacements
|
||||
(keyof Omit<TFormats, 'count'> extends never
|
||||
? {}
|
||||
: (
|
||||
| Expand<Omit<ReplaceOptionsFromFormats<TFormats, TValueType>, 'count'>>
|
||||
| {
|
||||
replace: Expand<
|
||||
Omit<ReplaceOptionsFromFormats<TFormats, TValueType>, 'count'>
|
||||
>;
|
||||
}
|
||||
) & {
|
||||
formatParams?: Expand<ReplaceFormatParamsFromFormats<TFormats>>;
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper type to only require options argument if needed
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type OptionArgs<TOptions extends {}> = keyof TOptions extends never
|
||||
? [options?: Expand<BaseOptions>]
|
||||
: [options: Expand<BaseOptions & TOptions>];
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
type TranslationFunctionOptions<
|
||||
TKeys extends keyof TMessages, // All normalized message keys to be considered, i.e. included nested ones
|
||||
TPluralKeys extends keyof TMessages, // All keys in the message map that are pluralized
|
||||
TMessages extends { [key in string]: string }, // Collapsed message map with normalized keys and union values
|
||||
TValueType,
|
||||
> = OptionArgs<
|
||||
Expand<
|
||||
CollectOptions<
|
||||
TKeys & TPluralKeys extends never ? {} : { count: number },
|
||||
ExpandRecursive<
|
||||
UnionToIntersection<ReplaceFormatsFromMessage<TMessages[TKeys]>>
|
||||
>,
|
||||
TValueType
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
/** @public */
|
||||
export type TranslationFunction<TMessages extends { [key in string]: string }> =
|
||||
CollapsedMessages<TMessages> extends infer IMessages extends {
|
||||
[key in string]: string;
|
||||
}
|
||||
? {
|
||||
/**
|
||||
* A translation function that returns a string.
|
||||
*/
|
||||
<TKey extends keyof IMessages>(
|
||||
key: TKey,
|
||||
...[args]: TranslationFunctionOptions<
|
||||
NestedMessageKeys<TKey, IMessages>,
|
||||
PluralKeys<TMessages>,
|
||||
IMessages,
|
||||
string
|
||||
>
|
||||
): IMessages[TKey];
|
||||
/**
|
||||
* A translation function where at least one JSX.Element has been
|
||||
* provided as an interpolation value, and will therefore return a
|
||||
* JSX.Element.
|
||||
*/
|
||||
<TKey extends keyof IMessages>(
|
||||
key: TKey,
|
||||
...[args]: TranslationFunctionOptions<
|
||||
NestedMessageKeys<TKey, IMessages>,
|
||||
PluralKeys<TMessages>,
|
||||
IMessages,
|
||||
string | JSX.Element
|
||||
>
|
||||
): JSX.Element;
|
||||
}
|
||||
: never;
|
||||
|
||||
/** @public */
|
||||
export type TranslationSnapshot<TMessages extends { [key in string]: string }> =
|
||||
{ ready: false } | { ready: true; t: TranslationFunction<TMessages> };
|
||||
|
||||
/** @public */
|
||||
export type TranslationApi = {
|
||||
getTranslation<TMessages extends { [key in string]: string }>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): TranslationSnapshot<TMessages>;
|
||||
|
||||
translation$<TMessages extends { [key in string]: string }>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): Observable<TranslationSnapshot<TMessages>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const translationApiRef: ApiRef<TranslationApi> = createApiRef({
|
||||
id: 'core.translation',
|
||||
});
|
||||
@@ -13,29 +13,496 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
/* We want to maintain the same information as an enum, so we disable the redeclaration warning */
|
||||
/* eslint-disable @typescript-eslint/no-redeclare */
|
||||
|
||||
export {
|
||||
type BackstageIdentityApi,
|
||||
type BackstageIdentityResponse,
|
||||
type BackstageUserIdentity,
|
||||
type AuthProviderInfo,
|
||||
type AuthRequestOptions,
|
||||
type OAuthScope,
|
||||
type OAuthApi,
|
||||
type OpenIdConnectApi,
|
||||
type ProfileInfoApi,
|
||||
type ProfileInfo,
|
||||
type SessionApi,
|
||||
SessionState,
|
||||
atlassianAuthApiRef,
|
||||
bitbucketAuthApiRef,
|
||||
bitbucketServerAuthApiRef,
|
||||
githubAuthApiRef,
|
||||
gitlabAuthApiRef,
|
||||
googleAuthApiRef,
|
||||
oktaAuthApiRef,
|
||||
microsoftAuthApiRef,
|
||||
oneloginAuthApiRef,
|
||||
vmwareCloudAuthApiRef,
|
||||
openshiftAuthApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { IconComponent } from '../../icons/types';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* This file contains declarations for common interfaces of auth-related APIs.
|
||||
* The declarations should be used to signal which type of authentication and
|
||||
* authorization methods each separate auth provider supports.
|
||||
*
|
||||
* For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect,
|
||||
* would be declared as follows:
|
||||
*
|
||||
* const googleAuthApiRef = createApiRef<OAuthApi & OpenIDConnectApi>({ ... })
|
||||
*/
|
||||
|
||||
/**
|
||||
* Information about the auth provider.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This information is used both to connect the correct auth provider in the backend, as
|
||||
* well as displaying the provider to the user.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AuthProviderInfo = {
|
||||
/**
|
||||
* The ID of the auth provider. This should match with ID of the provider in the `@backstage/auth-backend`.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Title for the auth provider, for example "GitHub"
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Icon for the auth provider.
|
||||
*/
|
||||
icon: IconComponent;
|
||||
|
||||
/**
|
||||
* Optional user friendly messaage to display for the auth provider.
|
||||
*/
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* An array of scopes, or a scope string formatted according to the
|
||||
* auth provider, which is typically a space separated list.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* See the documentation for each auth provider for the list of scopes
|
||||
* supported by each provider.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type OAuthScope = string | string[];
|
||||
|
||||
/**
|
||||
* Configuration of an authentication request.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AuthRequestOptions = {
|
||||
/**
|
||||
* If this is set to true, the user will not be prompted to log in,
|
||||
* and an empty response will be returned if there is no existing session.
|
||||
*
|
||||
* This can be used to perform a check whether the user is logged in, or if you don't
|
||||
* want to force a user to be logged in, but provide functionality if they already are.
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
optional?: boolean;
|
||||
|
||||
/**
|
||||
* If this is set to true, the request will bypass the regular oauth login modal
|
||||
* and open the login popup directly.
|
||||
*
|
||||
* The method must be called synchronously from a user action for this to work in all browsers.
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
instantPopup?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* This API provides access to OAuth 2 credentials. It lets you request access tokens,
|
||||
* which can be used to act on behalf of the user when talking to APIs.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type OAuthApi = {
|
||||
/**
|
||||
* Requests an OAuth 2 Access Token, optionally with a set of scopes. The access token allows
|
||||
* you to make requests on behalf of the user, and the copes may grant you broader access, depending
|
||||
* on the auth provider.
|
||||
*
|
||||
* Each auth provider has separate handling of scope, so you need to look at the documentation
|
||||
* for each one to know what scope you need to request.
|
||||
*
|
||||
* This method is cheap and should be called each time an access token is used. Do not for example
|
||||
* store the access token in React component state, as that could cause the token to expire. Instead
|
||||
* fetch a new access token for each request.
|
||||
*
|
||||
* Be sure to include all required scopes when requesting an access token. When testing your implementation
|
||||
* it is best to log out the Backstage session and then visit your plugin page directly, as
|
||||
* you might already have some required scopes in your existing session. Not requesting the correct
|
||||
* scopes can lead to 403 or other authorization errors, which can be tricky to debug.
|
||||
*
|
||||
* If the user has not yet granted access to the provider and the set of requested scopes, the user
|
||||
* will be prompted to log in. The returned promise will not resolve until the user has
|
||||
* successfully logged in. The returned promise can be rejected, but only if the user rejects the login request.
|
||||
*/
|
||||
getAccessToken(
|
||||
scope?: OAuthScope,
|
||||
options?: AuthRequestOptions,
|
||||
): Promise<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This API provides access to OpenID Connect credentials. It lets you request ID tokens,
|
||||
* which can be passed to backend services to prove the user's identity.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type OpenIdConnectApi = {
|
||||
/**
|
||||
* Requests an OpenID Connect ID Token.
|
||||
*
|
||||
* This method is cheap and should be called each time an ID token is used. Do not for example
|
||||
* store the id token in React component state, as that could cause the token to expire. Instead
|
||||
* fetch a new id token for each request.
|
||||
*
|
||||
* If the user has not yet logged in to Google inside Backstage, the user will be prompted
|
||||
* to log in. The returned promise will not resolve until the user has successfully logged in.
|
||||
* The returned promise can be rejected, but only if the user rejects the login request.
|
||||
*/
|
||||
getIdToken(options?: AuthRequestOptions): Promise<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This API provides access to profile information of the user from an auth provider.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ProfileInfoApi = {
|
||||
/**
|
||||
* Get profile information for the user as supplied by this auth provider.
|
||||
*
|
||||
* If the optional flag is not set, a session is guaranteed to be returned, while if
|
||||
* the optional flag is set, the session may be undefined. See {@link AuthRequestOptions} for more details.
|
||||
*/
|
||||
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This API provides access to the user's identity within Backstage.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* An auth provider that implements this interface can be used to sign-in to backstage. It is
|
||||
* not intended to be used directly from a plugin, but instead serves as a connection between
|
||||
* this authentication method and the app's {@link IdentityApi}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type BackstageIdentityApi = {
|
||||
/**
|
||||
* Get the user's identity within Backstage. This should normally not be called directly,
|
||||
* use the {@link IdentityApi} instead.
|
||||
*
|
||||
* If the optional flag is not set, a session is guaranteed to be returned, while if
|
||||
* the optional flag is set, the session may be undefined. See {@link AuthRequestOptions} for more details.
|
||||
*/
|
||||
getBackstageIdentity(
|
||||
options?: AuthRequestOptions,
|
||||
): Promise<BackstageIdentityResponse | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* User identity information within Backstage.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type BackstageUserIdentity = {
|
||||
/**
|
||||
* The type of identity that this structure represents. In the frontend app
|
||||
* this will currently always be 'user'.
|
||||
*/
|
||||
type: 'user';
|
||||
|
||||
/**
|
||||
* The entityRef of the user in the catalog.
|
||||
* For example User:default/sandra
|
||||
*/
|
||||
userEntityRef: string;
|
||||
|
||||
/**
|
||||
* The user and group entities that the user claims ownership through
|
||||
*/
|
||||
ownershipEntityRefs: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Token and Identity response, with the users claims in the Identity.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type BackstageIdentityResponse = {
|
||||
/**
|
||||
* The token used to authenticate the user within Backstage.
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* The time at which the token expires. If not set, it can be assumed that the token does not expire.
|
||||
*/
|
||||
expiresAt?: Date;
|
||||
|
||||
/**
|
||||
* Identity information derived from the token.
|
||||
*/
|
||||
identity: BackstageUserIdentity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Profile information of the user.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ProfileInfo = {
|
||||
/**
|
||||
* Email ID.
|
||||
*/
|
||||
email?: string;
|
||||
|
||||
/**
|
||||
* Display name that can be presented to the user.
|
||||
*/
|
||||
displayName?: string;
|
||||
|
||||
/**
|
||||
* URL to an avatar image of the user.
|
||||
*/
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session state values passed to subscribers of the SessionApi.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SessionState = {
|
||||
/**
|
||||
* User signed in.
|
||||
*/
|
||||
SignedIn: 'SignedIn',
|
||||
/**
|
||||
* User not signed in.
|
||||
*/
|
||||
SignedOut: 'SignedOut',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type SessionState = (typeof SessionState)[keyof typeof SessionState];
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export namespace SessionState {
|
||||
export type SignedIn = typeof SessionState.SignedIn;
|
||||
export type SignedOut = typeof SessionState.SignedOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SessionApi provides basic controls for any auth provider that is tied to a persistent session.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SessionApi = {
|
||||
/**
|
||||
* Sign in with a minimum set of permissions.
|
||||
*/
|
||||
signIn(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sign out from the current session. This will reload the page.
|
||||
*/
|
||||
signOut(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Observe the current state of the auth session. Emits the current state on subscription.
|
||||
*/
|
||||
sessionState$(): Observable<SessionState>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides authentication towards Google APIs and identities.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://developers.google.com/identity/protocols/googlescopes} for a full list of supported scopes.
|
||||
*
|
||||
* Note that the ID token payload is only guaranteed to contain the user's numerical Google ID,
|
||||
* email and expiration information. Do not rely on any other fields, as they might not be present.
|
||||
*/
|
||||
export const googleAuthApiRef: ApiRef<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.google',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards GitHub APIs.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/}
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const githubAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.github',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards Okta APIs.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/}
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const oktaAuthApiRef: ApiRef<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.okta',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards GitLab APIs.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token}
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const gitlabAuthApiRef: ApiRef<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.gitlab',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards Microsoft APIs and identities.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* For more info and a full list of supported scopes, see:
|
||||
* - {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent}
|
||||
* - {@link https://docs.microsoft.com/en-us/graph/permissions-reference}
|
||||
*/
|
||||
export const microsoftAuthApiRef: ApiRef<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.microsoft',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards OneLogin APIs.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const oneloginAuthApiRef: ApiRef<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.onelogin',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards Bitbucket APIs.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/}
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const bitbucketAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.bitbucket',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards Bitbucket Server APIs.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://confluence.atlassian.com/bitbucketserver/bitbucket-oauth-2-0-provider-api-1108483661.html#BitbucketOAuth2.0providerAPI-scopes}
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const bitbucketServerAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.bitbucket-server',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards Atlassian APIs.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://developer.atlassian.com/cloud/jira/platform/scopes-for-connect-and-oauth-2-3LO-apps/}
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const atlassianAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.atlassian',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards VMware Cloud APIs and identities.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* For more info about VMware Cloud identity and access management:
|
||||
* - {@link https://docs.vmware.com/en/VMware-Cloud-services/services/Using-VMware-Cloud-Services/GUID-53D39337-D93A-4B84-BD18-DDF43C21479A.html}
|
||||
*/
|
||||
export const vmwareCloudAuthApiRef: ApiRef<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.vmware-cloud',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards OpenShift APIs and identities.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/authentication_and_authorization/configuring-oauth-clients}
|
||||
* on how to configure the OAuth clients and
|
||||
* {@link https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html-single/authentication_and_authorization/index#tokens-scoping-about_configuring-internal-oauth}
|
||||
* for available scopes.
|
||||
*/
|
||||
export const openshiftAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.openshift',
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
export * from './auth';
|
||||
|
||||
export * from './AlertApi';
|
||||
export * from './AppLanguageApi';
|
||||
export * from './AppThemeApi';
|
||||
export * from './SwappableComponentsApi';
|
||||
export * from './ConfigApi';
|
||||
@@ -47,3 +48,4 @@ export * from './OAuthRequestApi';
|
||||
export * from './RouteResolutionApi';
|
||||
export * from './StorageApi';
|
||||
export * from './AnalyticsApi';
|
||||
export * from './TranslationApi';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2020 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 { createApiRef } from './ApiRef';
|
||||
|
||||
describe('ApiRef', () => {
|
||||
it('should be created', () => {
|
||||
const ref = createApiRef({ id: 'abc' });
|
||||
expect(ref.id).toBe('abc');
|
||||
expect(String(ref)).toBe('apiRef{abc}');
|
||||
expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
|
||||
});
|
||||
|
||||
it('should reject invalid ids', () => {
|
||||
for (const id of ['a', 'abc', 'ab-c', 'a.b.c', 'a-b.c', 'abc.a-b-c.abc3']) {
|
||||
expect(createApiRef({ id }).id).toBe(id);
|
||||
}
|
||||
|
||||
for (const id of [
|
||||
'123',
|
||||
'ab-3',
|
||||
'ab_c',
|
||||
'.',
|
||||
'2ac',
|
||||
'ab.3a',
|
||||
'.abc',
|
||||
'abc.',
|
||||
'ab..s',
|
||||
'',
|
||||
'_',
|
||||
]) {
|
||||
expect(() => createApiRef({ id }).id).toThrow(
|
||||
`API id must only contain period separated lowercase alphanum tokens with dashes, got '${id}'`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,51 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
type ApiRef,
|
||||
type ApiRefConfig,
|
||||
createApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import type { ApiRef } from './types';
|
||||
|
||||
/**
|
||||
* API reference configuration - holds an ID of the referenced API.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApiRefConfig = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
class ApiRefImpl<T> implements ApiRef<T> {
|
||||
constructor(private readonly config: ApiRefConfig) {
|
||||
const valid = config.id
|
||||
.split('.')
|
||||
.flatMap(part => part.split('-'))
|
||||
.every(part => part.match(/^[a-z][a-z0-9]*$/));
|
||||
if (!valid) {
|
||||
throw new Error(
|
||||
`API id must only contain period separated lowercase alphanum tokens with dashes, got '${config.id}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.config.id;
|
||||
}
|
||||
|
||||
// Utility for getting type of an api, using `typeof apiRef.T`
|
||||
get T(): T {
|
||||
throw new Error(`tried to read ApiRef.T of ${this}`);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `apiRef{${this.config.id}}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reference to an API.
|
||||
*
|
||||
* @param config - The descriptor of the API to reference.
|
||||
* @returns An API reference.
|
||||
* @public
|
||||
*/
|
||||
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> {
|
||||
return new ApiRefImpl<T>(config);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
* Copyright 2020 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.
|
||||
@@ -14,4 +14,61 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createApiFactory } from '@backstage/core-plugin-api';
|
||||
import { ApiRef, ApiFactory, TypesToApiRefs } from './types';
|
||||
|
||||
/**
|
||||
* Used to infer types for a standalone {@link ApiFactory} that isn't immediately passed
|
||||
* to another function.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This function doesn't actually do anything, it's only used to infer types.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createApiFactory<
|
||||
Api,
|
||||
Impl extends Api,
|
||||
Deps extends { [name in string]: unknown },
|
||||
>(factory: ApiFactory<Api, Impl, Deps>): ApiFactory<Api, Impl, Deps>;
|
||||
/**
|
||||
* Used to infer types for a standalone {@link ApiFactory} that isn't immediately passed
|
||||
* to another function.
|
||||
*
|
||||
* @param api - Ref of the API that will be produced by the factory.
|
||||
* @param instance - Implementation of the API to use.
|
||||
* @public
|
||||
*/
|
||||
export function createApiFactory<Api, Impl extends Api>(
|
||||
api: ApiRef<Api>,
|
||||
instance: Impl,
|
||||
): ApiFactory<Api, Impl, {}>;
|
||||
/**
|
||||
* Used to infer types for a standalone {@link ApiFactory} that isn't immediately passed
|
||||
* to another function.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Creates factory from {@link ApiRef} or returns the factory itself if provided.
|
||||
*
|
||||
* @param factory - Existing factory or {@link ApiRef}.
|
||||
* @param instance - The instance to be returned by the factory.
|
||||
* @public
|
||||
*/
|
||||
export function createApiFactory<
|
||||
Api,
|
||||
Impl extends Api,
|
||||
Deps extends { [name in string]: unknown },
|
||||
>(
|
||||
factory: ApiFactory<Api, Impl, Deps> | ApiRef<Api>,
|
||||
instance?: Impl,
|
||||
): ApiFactory<Api, Impl, Deps> {
|
||||
if ('id' in factory) {
|
||||
return {
|
||||
api: factory,
|
||||
deps: {} as TypesToApiRefs<Deps>,
|
||||
factory: () => instance!,
|
||||
};
|
||||
}
|
||||
return factory;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
* Copyright 2020 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
* Copyright 2020 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.
|
||||
@@ -14,11 +14,61 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type {
|
||||
ApiRef,
|
||||
AnyApiRef,
|
||||
TypesToApiRefs,
|
||||
ApiHolder,
|
||||
ApiFactory,
|
||||
AnyApiFactory,
|
||||
} from '@backstage/core-plugin-api';
|
||||
/**
|
||||
* API reference.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApiRef<T> = {
|
||||
id: string;
|
||||
T: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Catch-all {@link ApiRef} type.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AnyApiRef = ApiRef<unknown>;
|
||||
|
||||
/**
|
||||
* Wraps a type with API properties into a type holding their respective {@link ApiRef}s.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type TypesToApiRefs<T> = { [key in keyof T]: ApiRef<T[key]> };
|
||||
|
||||
/**
|
||||
* Provides lookup of APIs through their {@link ApiRef}s.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApiHolder = {
|
||||
get<T>(api: ApiRef<T>): T | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes type returning API implementations.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApiFactory<
|
||||
Api,
|
||||
Impl extends Api,
|
||||
Deps extends { [name in string]: unknown },
|
||||
> = {
|
||||
api: ApiRef<Api>;
|
||||
deps: TypesToApiRefs<Deps>;
|
||||
factory(deps: Deps): Impl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Catch-all {@link ApiFactory} type.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AnyApiFactory = ApiFactory<
|
||||
unknown,
|
||||
unknown,
|
||||
{ [key in string]: unknown }
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2020 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 { renderHook } from '@testing-library/react';
|
||||
import { createVersionedContextForTesting } from '@backstage/version-bridge';
|
||||
import { createApiRef } from './ApiRef';
|
||||
import { useApi } from './useApi';
|
||||
|
||||
describe('useApi', () => {
|
||||
const context = createVersionedContextForTesting('api-context');
|
||||
|
||||
afterEach(() => {
|
||||
context.reset();
|
||||
});
|
||||
|
||||
it('should resolve routes', () => {
|
||||
const get = jest.fn(() => 'my-api-impl');
|
||||
context.set({ 1: { get } });
|
||||
|
||||
const apiRef = createApiRef<string>({ id: 'x' });
|
||||
const renderedHook = renderHook(() => useApi(apiRef));
|
||||
|
||||
const value = renderedHook.result.current;
|
||||
expect(value).toBe('my-api-impl');
|
||||
expect(get).toHaveBeenCalledWith(apiRef);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
* Copyright 2020 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.
|
||||
@@ -14,4 +14,81 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { useApiHolder, useApi, withApis } from '@backstage/core-plugin-api';
|
||||
import { ComponentType, PropsWithChildren } from 'react';
|
||||
import { ApiRef, ApiHolder, TypesToApiRefs } from './types';
|
||||
import { useVersionedContext } from '@backstage/version-bridge';
|
||||
import { NotImplementedError } from '@backstage/errors';
|
||||
|
||||
/**
|
||||
* React hook for retrieving {@link ApiHolder}, an API catalog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useApiHolder(): ApiHolder {
|
||||
const versionedHolder = useVersionedContext<{ 1: ApiHolder }>('api-context');
|
||||
if (!versionedHolder) {
|
||||
throw new NotImplementedError('API context is not available');
|
||||
}
|
||||
|
||||
const apiHolder = versionedHolder.atVersion(1);
|
||||
if (!apiHolder) {
|
||||
throw new NotImplementedError('ApiContext v1 not available');
|
||||
}
|
||||
return apiHolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for retrieving APIs.
|
||||
*
|
||||
* @param apiRef - Reference of the API to use.
|
||||
* @public
|
||||
*/
|
||||
export function useApi<T>(apiRef: ApiRef<T>): T {
|
||||
const apiHolder = useApiHolder();
|
||||
|
||||
const api = apiHolder.get(apiRef);
|
||||
if (!api) {
|
||||
throw new NotImplementedError(`No implementation available for ${apiRef}`);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for giving component an API context.
|
||||
*
|
||||
* @param apis - APIs for the context.
|
||||
* @public
|
||||
*/
|
||||
export function withApis<T extends {}>(apis: TypesToApiRefs<T>) {
|
||||
return function withApisWrapper<TProps extends T>(
|
||||
WrappedComponent: ComponentType<TProps>,
|
||||
) {
|
||||
const Hoc = (props: PropsWithChildren<Omit<TProps, keyof T>>) => {
|
||||
const apiHolder = useApiHolder();
|
||||
|
||||
const impls = {} as T;
|
||||
|
||||
for (const key in apis) {
|
||||
if (apis.hasOwnProperty(key)) {
|
||||
const ref = apis[key];
|
||||
|
||||
const api = apiHolder.get(ref);
|
||||
if (!api) {
|
||||
throw new NotImplementedError(
|
||||
`No implementation available for ${ref}`,
|
||||
);
|
||||
}
|
||||
impls[key] = api;
|
||||
}
|
||||
}
|
||||
|
||||
return <WrappedComponent {...(props as TProps)} {...impls} />;
|
||||
};
|
||||
const displayName =
|
||||
WrappedComponent.displayName || WrappedComponent.name || 'Component';
|
||||
|
||||
Hoc.displayName = `withApis(${displayName})`;
|
||||
|
||||
return Hoc;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { createExtensionInput } from '../wiring';
|
||||
import { ApiBlueprint } from './ApiBlueprint';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { createApiRef } from '../apis/system';
|
||||
|
||||
describe('ApiBlueprint', () => {
|
||||
it('should create an extension with sensible defaults', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { IconComponent } from '../icons/types';
|
||||
import { RouteRef } from '../routing';
|
||||
import { createExtensionBlueprint, createExtensionDataRef } from '../wiring';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { AppTheme } from '@backstage/core-plugin-api';
|
||||
import { AppTheme } from '../apis/definitions/AppThemeApi';
|
||||
import { ThemeBlueprint } from './ThemeBlueprint';
|
||||
import { createExtensionTester } from '@backstage/frontend-test-utils';
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppTheme } from '@backstage/core-plugin-api';
|
||||
import { AppTheme } from '../apis/definitions/AppThemeApi';
|
||||
import { createExtensionBlueprint, createExtensionDataRef } from '../wiring';
|
||||
|
||||
const themeDataRef = createExtensionDataRef<AppTheme>().with({
|
||||
|
||||
@@ -16,14 +16,11 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
mockApis,
|
||||
TestApiProvider,
|
||||
withLogCollector,
|
||||
} from '@backstage/test-utils';
|
||||
import { TestApiProvider, withLogCollector } from '@backstage/test-utils';
|
||||
import { ExtensionBoundary } from './ExtensionBoundary';
|
||||
import { coreExtensionData, createExtension } from '../wiring';
|
||||
import { analyticsApiRef, useAnalytics } from '@backstage/core-plugin-api';
|
||||
import { analyticsApiRef } from '../apis/definitions/AnalyticsApi';
|
||||
import { useAnalytics } from '../analytics';
|
||||
import { createRouteRef } from '../routing';
|
||||
import {
|
||||
createExtensionTester,
|
||||
@@ -93,7 +90,7 @@ describe('ExtensionBoundary', () => {
|
||||
it('should wrap children with analytics context', async () => {
|
||||
const action = 'render';
|
||||
const subject = 'analytics';
|
||||
const analyticsApiMock = mockApis.analytics();
|
||||
const analyticsApiMock = { captureEvent: jest.fn() };
|
||||
|
||||
const AnalyticsComponent = () => {
|
||||
const analytics = useAnalytics();
|
||||
@@ -134,7 +131,7 @@ describe('ExtensionBoundary', () => {
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const analyticsApiMock = mockApis.analytics();
|
||||
const analyticsApiMock = { captureEvent: jest.fn() };
|
||||
|
||||
await act(async () => {
|
||||
renderInTestApp(
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
useEffect,
|
||||
lazy as reactLazy,
|
||||
} from 'react';
|
||||
import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsContext, useAnalytics } from '../analytics';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('ExtensionSuspense', () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('progress')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('core-progress')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the lazy loaded children component', async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { ReactNode, Suspense } from 'react';
|
||||
import { useApp } from '@backstage/core-plugin-api';
|
||||
import { Progress } from './DefaultSwappableComponents';
|
||||
|
||||
/** @public */
|
||||
export interface ExtensionSuspenseProps {
|
||||
@@ -26,8 +26,5 @@ export interface ExtensionSuspenseProps {
|
||||
export function ExtensionSuspense(props: ExtensionSuspenseProps) {
|
||||
const { children } = props;
|
||||
|
||||
const app = useApp();
|
||||
const { Progress } = app.getComponents();
|
||||
|
||||
return <Suspense fallback={<Progress />}>{children}</Suspense>;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 './TranslationRef';
|
||||
|
||||
/**
|
||||
* Represents a collection of messages to be provided for a given translation ref.
|
||||
*
|
||||
* @public
|
||||
* @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}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface TranslationMessagesOptions<
|
||||
TId extends string,
|
||||
TMessages extends { [key in string]: string },
|
||||
TFull extends boolean,
|
||||
> {
|
||||
ref: TranslationRef<TId, TMessages>;
|
||||
|
||||
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.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createTranslationMessages<
|
||||
TId extends string,
|
||||
TMessages extends { [key in string]: string },
|
||||
TFull extends boolean,
|
||||
>(
|
||||
options: TranslationMessagesOptions<TId, TMessages, TFull>,
|
||||
): TranslationMessages<TId, TMessages, TFull> {
|
||||
return {
|
||||
$$type: '@backstage/TranslationMessages',
|
||||
id: options.ref.id,
|
||||
full: Boolean(options.full) as TFull,
|
||||
messages: options.messages as TMessages,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 {
|
||||
createTranslationRef,
|
||||
toInternalTranslationRef,
|
||||
} from './TranslationRef';
|
||||
import { toInternalTranslationResource } from './TranslationResource';
|
||||
|
||||
describe('TranslationRefImpl', () => {
|
||||
it('should create a TranslationRef instance using the factory function', () => {
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages: { key: 'value' },
|
||||
});
|
||||
|
||||
const internalRef = toInternalTranslationRef(ref);
|
||||
|
||||
expect(internalRef.$$type).toBe('@backstage/TranslationRef');
|
||||
expect(internalRef.version).toBe('v1');
|
||||
expect(internalRef.id).toBe('test');
|
||||
expect(internalRef.getDefaultMessages()).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should create a TranslationRef instance with nested messages', () => {
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages: {
|
||||
key: 'value',
|
||||
'nested.conflict1': 'outer conflict1',
|
||||
nested: {
|
||||
key: 'nested value',
|
||||
key2: 'nested value2',
|
||||
conflict1: 'inner conflict1',
|
||||
conflict2: 'inner conflict2',
|
||||
inner: {
|
||||
key: 'inner value',
|
||||
},
|
||||
},
|
||||
'nested.conflict2': 'outer conflict2',
|
||||
},
|
||||
});
|
||||
|
||||
const internalRef = toInternalTranslationRef(ref);
|
||||
|
||||
expect(internalRef.$$type).toBe('@backstage/TranslationRef');
|
||||
expect(internalRef.version).toBe('v1');
|
||||
expect(internalRef.id).toBe('test');
|
||||
expect(internalRef.getDefaultMessages()).toEqual({
|
||||
key: 'value',
|
||||
'nested.key': 'nested value',
|
||||
'nested.key2': 'nested value2',
|
||||
'nested.conflict1': 'inner conflict1',
|
||||
'nested.inner.key': 'inner value',
|
||||
'nested.conflict2': 'outer conflict2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be created with lazy translations', async () => {
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages: { key: 'value' },
|
||||
translations: {
|
||||
de: () => Promise.resolve({ default: { key: 'other-value' } }),
|
||||
},
|
||||
});
|
||||
|
||||
const internalRef = toInternalTranslationRef(ref);
|
||||
|
||||
expect(internalRef.$$type).toBe('@backstage/TranslationRef');
|
||||
expect(internalRef.version).toBe('v1');
|
||||
expect(internalRef.id).toBe('test');
|
||||
expect(internalRef.getDefaultMessages()).toEqual({ 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* 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,
|
||||
TranslationResource,
|
||||
} from './TranslationResource';
|
||||
|
||||
/** @public */
|
||||
export interface TranslationRef<
|
||||
TId extends string = string,
|
||||
TMessages extends { [key in string]: string } = { [key in string]: string },
|
||||
> {
|
||||
$$type: '@backstage/TranslationRef';
|
||||
|
||||
id: TId;
|
||||
|
||||
T: TMessages;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
type AnyMessages = { [key in string]: string };
|
||||
|
||||
/** @ignore */
|
||||
type AnyNestedMessages = { [key in string]: AnyNestedMessages | string };
|
||||
|
||||
/**
|
||||
* Flattens a nested message declaration into a flat object with dot-separated keys.
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type FlattenedMessages<TMessages extends AnyNestedMessages> =
|
||||
// Flatten out object keys into a union structure of objects, e.g. { a: 'a', b: 'b' } -> { a: 'a' } | { b: 'b' }
|
||||
// Any nested object will be flattened into the individual unions, e.g. { a: 'a', b: { x: 'x', y: 'y' } } -> { a: 'a' } | { 'b.x': 'x', 'b.y': 'y' }
|
||||
// We create this structure by first nesting the desired union types into the original object, and
|
||||
// then extract them by indexing with `keyof TMessages` to form the union.
|
||||
// Throughout this the objects are wrapped up in a function parameter, which allows us to have the
|
||||
// final step of flipping this unions around to an intersection by inferring the function parameter.
|
||||
{
|
||||
[TKey in keyof TMessages]: (
|
||||
_: TMessages[TKey] extends infer TValue // "local variable" for the value
|
||||
? TValue extends AnyNestedMessages
|
||||
? FlattenedMessages<TValue> extends infer TNested // Recurse into nested messages, "local variable" for the result
|
||||
? {
|
||||
[TNestedKey in keyof TNested as `${TKey & string}.${TNestedKey &
|
||||
string}`]: TNested[TNestedKey];
|
||||
}
|
||||
: never
|
||||
: { [_ in TKey]: TValue } // Primitive object values are passed through with the same key
|
||||
: never,
|
||||
) => void;
|
||||
// The `[keyof TMessages]` extracts the object values union from our flattened structure, still wrapped up in function parameters.
|
||||
// The `extends (_: infer TIntersection) => void` flips the union to an intersection, at which point we have the correct type.
|
||||
}[keyof TMessages] extends (_: infer TIntersection) => void
|
||||
? // This object mapping just expands similar to the Expand<> utility type, providing nicer type hints
|
||||
{
|
||||
readonly [TExpandKey in keyof TIntersection]: TIntersection[TExpandKey];
|
||||
}
|
||||
: never;
|
||||
|
||||
/** @internal */
|
||||
export interface InternalTranslationRef<
|
||||
TId extends string = string,
|
||||
TMessages extends { [key in string]: string } = { [key in string]: string },
|
||||
> extends TranslationRef<TId, TMessages> {
|
||||
version: 'v1';
|
||||
|
||||
getDefaultMessages(): AnyMessages;
|
||||
|
||||
getDefaultResource(): TranslationResource | undefined;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface TranslationRefOptions<
|
||||
TId extends string,
|
||||
TNestedMessages extends AnyNestedMessages,
|
||||
TTranslations extends {
|
||||
[language in string]: () => Promise<{
|
||||
default: {
|
||||
[key in keyof FlattenedMessages<TNestedMessages>]: string | null;
|
||||
};
|
||||
}>;
|
||||
},
|
||||
> {
|
||||
id: TId;
|
||||
messages: TNestedMessages;
|
||||
translations?: TTranslations;
|
||||
}
|
||||
|
||||
function flattenMessages(nested: AnyNestedMessages): AnyMessages {
|
||||
const entries = new Array<[string, string]>();
|
||||
|
||||
function visit(obj: AnyNestedMessages, prefix: string): void {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string') {
|
||||
entries.push([prefix + key, value]);
|
||||
} else {
|
||||
visit(value, `${prefix}${key}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(nested, '');
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
class TranslationRefImpl<
|
||||
TId extends string,
|
||||
TNestedMessages extends AnyNestedMessages,
|
||||
> implements InternalTranslationRef<TId, FlattenedMessages<TNestedMessages>>
|
||||
{
|
||||
#id: TId;
|
||||
#messages: FlattenedMessages<TNestedMessages>;
|
||||
#resources: TranslationResource | undefined;
|
||||
|
||||
constructor(options: TranslationRefOptions<TId, TNestedMessages, any>) {
|
||||
this.#id = options.id;
|
||||
this.#messages = flattenMessages(
|
||||
options.messages,
|
||||
) as FlattenedMessages<TNestedMessages>;
|
||||
}
|
||||
|
||||
$$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}}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createTranslationRef<
|
||||
TId extends string,
|
||||
const TNestedMessages extends AnyNestedMessages,
|
||||
TTranslations extends {
|
||||
[language in string]: () => Promise<{
|
||||
default: {
|
||||
[key in keyof FlattenedMessages<TNestedMessages>]: string | null;
|
||||
};
|
||||
}>;
|
||||
},
|
||||
>(
|
||||
config: TranslationRefOptions<TId, TNestedMessages, TTranslations>,
|
||||
): TranslationRef<TId, FlattenedMessages<TNestedMessages>> {
|
||||
const ref = new TranslationRefImpl(config);
|
||||
if (config.translations) {
|
||||
ref.setDefaultResource(
|
||||
createTranslationResource({
|
||||
ref,
|
||||
translations: config.translations as any,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function toInternalTranslationRef<
|
||||
TId extends string,
|
||||
TMessages extends AnyMessages,
|
||||
>(ref: TranslationRef<TId, TMessages>): InternalTranslationRef<TId, TMessages> {
|
||||
const r = ref as InternalTranslationRef<TId, TMessages>;
|
||||
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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 } from './TranslationMessages';
|
||||
import { TranslationRef } from './TranslationRef';
|
||||
|
||||
/** @public */
|
||||
export interface TranslationResource<TId extends string = string> {
|
||||
$$type: '@backstage/TranslationResource';
|
||||
id: TId;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type InternalTranslationResourceLoader = () => Promise<{
|
||||
messages: { [key in string]: string | null };
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
export interface InternalTranslationResource<TId extends string = string>
|
||||
extends TranslationResource<TId> {
|
||||
version: 'v1';
|
||||
resources: Array<{
|
||||
language: string;
|
||||
loader: InternalTranslationResourceLoader;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function toInternalTranslationResource<TId extends string>(
|
||||
resource: TranslationResource<TId>,
|
||||
): InternalTranslationResource<TId> {
|
||||
const r = resource as InternalTranslationResource<TId>;
|
||||
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;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface TranslationResourceOptions<
|
||||
TId extends string,
|
||||
TMessages extends { [key in string]: string },
|
||||
TTranslations extends {
|
||||
[language in string]: () => Promise<{
|
||||
default:
|
||||
| TranslationMessages<TId>
|
||||
| { [key in keyof TMessages]: string | null };
|
||||
}>;
|
||||
},
|
||||
> {
|
||||
ref: TranslationRef<TId, TMessages>;
|
||||
|
||||
translations: TTranslations;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createTranslationResource<
|
||||
TId extends string,
|
||||
TMessages extends { [key in string]: string },
|
||||
TTranslations extends {
|
||||
[language in string]: () => Promise<{
|
||||
default:
|
||||
| TranslationMessages<TId>
|
||||
| { [key in keyof TMessages]: string | null };
|
||||
}>;
|
||||
},
|
||||
>(
|
||||
options: TranslationResourceOptions<TId, TMessages, TTranslations>,
|
||||
): TranslationResource<TId> {
|
||||
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<TId>;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"one": "ett",
|
||||
"two": "två",
|
||||
"three": "tre"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"apple": "apfel",
|
||||
"orange": "apfelsine"
|
||||
}
|
||||
+10
-1
@@ -14,4 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { appLanguageApiRef, type AppLanguageApi } from './AppLanguageApi';
|
||||
import { createTranslationMessages } from '../TranslationMessages';
|
||||
import { fruitsTranslationRef } from './refs';
|
||||
|
||||
export default createTranslationMessages({
|
||||
ref: fruitsTranslationRef,
|
||||
messages: {
|
||||
apple: 'äpple',
|
||||
orange: 'apelsin',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 { createTranslationRef } from '../TranslationRef';
|
||||
|
||||
export const countingTranslationRef = createTranslationRef({
|
||||
id: 'counting',
|
||||
messages: {
|
||||
one: 'one',
|
||||
two: 'two',
|
||||
three: 'three',
|
||||
},
|
||||
});
|
||||
|
||||
export const fruitsTranslationRef = createTranslationRef({
|
||||
id: 'fruits',
|
||||
messages: {
|
||||
apple: 'apple',
|
||||
orange: 'orange',
|
||||
},
|
||||
translations: {
|
||||
de: () => import('./fruits-de.json'),
|
||||
},
|
||||
});
|
||||
@@ -17,12 +17,16 @@
|
||||
export {
|
||||
type TranslationMessages,
|
||||
type TranslationMessagesOptions,
|
||||
createTranslationMessages,
|
||||
} from './TranslationMessages';
|
||||
export {
|
||||
type TranslationResource,
|
||||
type TranslationResourceOptions,
|
||||
createTranslationResource,
|
||||
} from './TranslationResource';
|
||||
export {
|
||||
type TranslationRef,
|
||||
type TranslationRefOptions,
|
||||
createTranslationMessages,
|
||||
createTranslationResource,
|
||||
createTranslationRef,
|
||||
useTranslationRef,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
} from './TranslationRef';
|
||||
export { useTranslationRef } from './useTranslationRef';
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* 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 { ReactNode } from 'react';
|
||||
import {
|
||||
MockErrorApi,
|
||||
TestApiProvider,
|
||||
withLogCollector,
|
||||
} from '@backstage/test-utils';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { createTranslationRef, TranslationRef } from './TranslationRef';
|
||||
import { useTranslationRef } from './useTranslationRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { I18nextTranslationApi } from '../../../core-app-api/src/apis/implementations/TranslationApi';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppLanguageSelector } from '../../..//core-app-api/src/apis/implementations/AppLanguageApi';
|
||||
import { createTranslationResource } from './TranslationResource';
|
||||
import {
|
||||
TranslationApi,
|
||||
translationApiRef,
|
||||
} from '../apis/definitions/TranslationApi';
|
||||
import { ErrorApi, errorApiRef } from '../apis';
|
||||
|
||||
const plainRef = createTranslationRef({
|
||||
id: 'plain',
|
||||
messages: {
|
||||
key1: 'default1',
|
||||
key2: 'default2',
|
||||
},
|
||||
});
|
||||
|
||||
function makeWrapper(
|
||||
translationApi: TranslationApi,
|
||||
errorApi: ErrorApi = { error$: jest.fn(), post: jest.fn() },
|
||||
) {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[translationApiRef, translationApi],
|
||||
[errorApiRef, errorApi],
|
||||
]}
|
||||
children={children}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useTranslationRef', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show default translations', () => {
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({ languageApi });
|
||||
|
||||
const { result } = renderHook(() => useTranslationRef(plainRef), {
|
||||
wrapper: makeWrapper(translationApi),
|
||||
});
|
||||
|
||||
const { t } = result.current;
|
||||
|
||||
expect(t('key1')).toBe('default1');
|
||||
expect(t('key2')).toBe('default2');
|
||||
});
|
||||
|
||||
it('should show load translation resource', async () => {
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: {
|
||||
en: () =>
|
||||
Promise.resolve({ default: { key1: 'en1', key2: 'en2' } }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTranslationRef(plainRef), {
|
||||
wrapper: makeWrapper(translationApi),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const { t } = result.current;
|
||||
expect(t('key1')).toBe('en1');
|
||||
expect(t('key2')).toBe('en2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch between languages', async () => {
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'de'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref: plainRef,
|
||||
translations: {
|
||||
de: () =>
|
||||
Promise.resolve({ default: { key1: 'de1', key2: 'de2' } }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTranslationRef(plainRef), {
|
||||
wrapper: makeWrapper(translationApi),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const { t } = result.current;
|
||||
|
||||
expect(t('key1')).toBe('default1');
|
||||
expect(t('key2')).toBe('default2');
|
||||
});
|
||||
|
||||
languageApi.setLanguage('de');
|
||||
|
||||
await waitFor(() => {
|
||||
const { t: t2 } = result.current;
|
||||
|
||||
expect(t2('key1')).toBe('de1');
|
||||
expect(t2('key2')).toBe('de2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should load default resource', async () => {
|
||||
const resourceRef = createTranslationRef({
|
||||
id: 'resource',
|
||||
messages: {
|
||||
key1: 'default1',
|
||||
key2: 'default2',
|
||||
},
|
||||
translations: {
|
||||
de: () => Promise.resolve({ default: { key1: 'de1', key2: 'de2' } }),
|
||||
},
|
||||
});
|
||||
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
defaultLanguage: 'de',
|
||||
availableLanguages: ['en', 'de'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTranslationRef(resourceRef), {
|
||||
wrapper: makeWrapper(translationApi),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const { t } = result.current;
|
||||
|
||||
expect(t('key1')).toBe('de1');
|
||||
expect(t('key2')).toBe('de2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should log once and then ignore loading errors', async () => {
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages: {
|
||||
key: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const errorApi = new MockErrorApi({ collect: true });
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref,
|
||||
translations: {
|
||||
en: async () => {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const rendered1 = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
const rendered2 = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
await act(rendered2.rerender);
|
||||
});
|
||||
|
||||
const msg =
|
||||
"Failed to load translation resource 'test'; caused by Error: NOPE";
|
||||
expect(error).toEqual([msg]);
|
||||
expect(errorApi.getErrors()).toEqual([
|
||||
{
|
||||
error: new Error(msg),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rendered1.result.current.t('key')).toBe('default');
|
||||
expect(rendered2.result.current.t('key')).toBe('default');
|
||||
});
|
||||
|
||||
it('should log once and then ignore loading errors after initial load', async () => {
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages: {
|
||||
key: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const errorApi = new MockErrorApi({ collect: true });
|
||||
const languageApi = AppLanguageSelector.create({
|
||||
availableLanguages: ['en', 'de'],
|
||||
});
|
||||
const translationApi = I18nextTranslationApi.create({
|
||||
languageApi,
|
||||
resources: [
|
||||
createTranslationResource({
|
||||
ref,
|
||||
translations: {
|
||||
de: async () => {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
|
||||
expect(result.current.t('key')).toBe('default');
|
||||
languageApi.setLanguage('de');
|
||||
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
const rendered1 = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
const rendered2 = renderHook(() => useTranslationRef(ref), {
|
||||
wrapper: makeWrapper(translationApi, errorApi),
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve)); // Wait a long tick
|
||||
|
||||
expect(rendered1.result.current.t('key')).toBe('default');
|
||||
expect(rendered2.result.current.t('key')).toBe('default');
|
||||
});
|
||||
|
||||
const msg =
|
||||
"Failed to load translation resource 'test'; caused by Error: NOPE";
|
||||
expect(error).toEqual([msg]);
|
||||
expect(errorApi.getErrors()).toEqual([
|
||||
{
|
||||
error: new Error(msg),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle translationRef switches', async () => {
|
||||
const ref1 = createTranslationRef({
|
||||
id: 'test1',
|
||||
messages: {
|
||||
key: 'default1',
|
||||
},
|
||||
});
|
||||
const ref2 = createTranslationRef({
|
||||
id: 'test2',
|
||||
messages: {
|
||||
key: 'default2',
|
||||
},
|
||||
});
|
||||
|
||||
const languageApi = AppLanguageSelector.create();
|
||||
const translationApi = I18nextTranslationApi.create({ languageApi });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({
|
||||
translationRef,
|
||||
}: {
|
||||
translationRef: TranslationRef;
|
||||
children?: ReactNode;
|
||||
}) => useTranslationRef(translationRef),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[translationApiRef, translationApi],
|
||||
[errorApiRef, { post: jest.fn() }],
|
||||
]}
|
||||
children={children}
|
||||
/>
|
||||
),
|
||||
initialProps: { translationRef: ref1 as TranslationRef },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.t('key')).toBe('default1');
|
||||
rerender({ translationRef: ref2 });
|
||||
expect(result.current.t('key')).toBe('default2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { errorApiRef, useApi } from '../apis';
|
||||
import {
|
||||
translationApiRef,
|
||||
TranslationFunction,
|
||||
TranslationSnapshot,
|
||||
} from '../apis/definitions/TranslationApi';
|
||||
import { TranslationRef } from './TranslationRef';
|
||||
|
||||
// Make sure we don't fill the logs with loading errors for the same ref
|
||||
const loggedRefs = new WeakSet<TranslationRef<string, {}>>();
|
||||
|
||||
/** @public */
|
||||
export const useTranslationRef = <
|
||||
TMessages extends { [key in string]: string },
|
||||
>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): { t: TranslationFunction<TMessages> } => {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const translationApi = useApi(translationApiRef);
|
||||
|
||||
const [snapshot, setSnapshot] = useState<TranslationSnapshot<TMessages>>(() =>
|
||||
translationApi.getTranslation(translationRef),
|
||||
);
|
||||
const observable = useMemo(
|
||||
() => translationApi.translation$(translationRef),
|
||||
[translationApi, translationRef],
|
||||
);
|
||||
|
||||
const onError = useCallback(
|
||||
(error: Error) => {
|
||||
if (!loggedRefs.has(translationRef)) {
|
||||
const errMsg = `Failed to load translation resource '${translationRef.id}'; caused by ${error}`;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(errMsg);
|
||||
errorApi.post(new Error(errMsg));
|
||||
loggedRefs.add(translationRef);
|
||||
}
|
||||
},
|
||||
[errorApi, translationRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = observable.subscribe({
|
||||
next(next) {
|
||||
if (next.ready) {
|
||||
setSnapshot(next);
|
||||
}
|
||||
},
|
||||
error(error) {
|
||||
onError(error);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [observable, onError]);
|
||||
|
||||
// Keep track of if the provided translation ref changes, and in that case update the snapshot
|
||||
const initialRenderRef = useRef(true);
|
||||
useEffect(() => {
|
||||
if (initialRenderRef.current) {
|
||||
initialRenderRef.current = false;
|
||||
} else {
|
||||
setSnapshot(translationApi.getTranslation(translationRef));
|
||||
}
|
||||
}, [translationApi, translationRef]);
|
||||
|
||||
if (!snapshot.ready) {
|
||||
throw new Promise<void>(resolve => {
|
||||
const subscription = observable.subscribe({
|
||||
next(next) {
|
||||
if (next.ready) {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
error(error) {
|
||||
subscription.unsubscribe();
|
||||
onError(error);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { t: snapshot.t };
|
||||
};
|
||||
Reference in New Issue
Block a user