Merge master and update toastApiRef to new ApiRef pattern
Resolve merge conflicts from master introducing the new ApiRef_2 type
pattern with `.with()` builder. Update toastApiRef to use the same
`createApiRef<T>().with({ id, pluginId })` pattern as all other API
refs, and regenerate API reports.
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
@@ -43,6 +43,8 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-app-react": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/plugin-permission-react": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
|
||||
import {
|
||||
AppTreeApi,
|
||||
ApiBlueprint,
|
||||
appTreeApiRef,
|
||||
coreExtensionData,
|
||||
createApiRef,
|
||||
createExtensionDataRef,
|
||||
createExtension,
|
||||
createExtensionBlueprint,
|
||||
createExtensionInput,
|
||||
PageBlueprint,
|
||||
createFrontendPlugin,
|
||||
createFrontendFeatureLoader,
|
||||
@@ -27,12 +32,22 @@ import {
|
||||
FrontendPluginInfo,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ThemeBlueprint } from '@backstage/plugin-app-react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import { createApp } from './createApp';
|
||||
import { mockApis, renderWithEffects } from '@backstage/test-utils';
|
||||
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
featureFlagsApiRef,
|
||||
IdentityApi,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { default as appPluginOriginal } from '@backstage/plugin-app';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ComponentType, useState, useEffect } from 'react';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
|
||||
const signInPageComponentDataRef = createExtensionDataRef<
|
||||
ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
|
||||
>().with({ id: 'core.sign-in-page.component' });
|
||||
|
||||
describe('createApp', () => {
|
||||
const appPlugin = appPluginOriginal.withOverrides({
|
||||
@@ -43,6 +58,25 @@ describe('createApp', () => {
|
||||
],
|
||||
});
|
||||
|
||||
function createFeatureFlagsApi(activeFlags: string[]) {
|
||||
return {
|
||||
isActive: jest.fn((name: string) => activeFlags.includes(name)),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
}
|
||||
|
||||
function createPermissionApi(allowedPermissions: string[]) {
|
||||
return {
|
||||
authorize: jest.fn(async request => ({
|
||||
result: allowedPermissions.includes(request.permission.name)
|
||||
? AuthorizeResult.ALLOW
|
||||
: AuthorizeResult.DENY,
|
||||
})),
|
||||
} as typeof permissionApiRef.T;
|
||||
}
|
||||
|
||||
it('should allow themes to be installed', async () => {
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
@@ -84,6 +118,184 @@ describe('createApp', () => {
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide app APIs to sign-in pages before finalization', async () => {
|
||||
const signInApiRef = createApiRef<{ value: string }>({
|
||||
id: 'test.sign-in-api',
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: signInApiRef,
|
||||
deps: {},
|
||||
factory: () => ({ value: 'ok' }),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
const SignInPage = () => {
|
||||
const api = useApi(signInApiRef);
|
||||
return <div>Sign In API: {api.value}</div>;
|
||||
};
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Sign In API: ok'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide feature flags to sign-in pages before finalization', async () => {
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
const SignInPage = () => {
|
||||
const flagsApi = useApi(featureFlagsApiRef);
|
||||
return (
|
||||
<div>
|
||||
Flags:{' '}
|
||||
{flagsApi
|
||||
.getRegisteredFlags()
|
||||
.map(flag => flag.name)
|
||||
.join(', ')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Flags: test-flag'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should surface sign-in bootstrap errors through the app root boundary', async () => {
|
||||
const identityApi = {
|
||||
getProfileInfo: async () => ({ displayName: 'Test User' }),
|
||||
getBackstageIdentity: async () => ({
|
||||
type: 'user' as const,
|
||||
userEntityRef: 'user:default/test-user',
|
||||
ownershipEntityRefs: ['user:default/test-user'],
|
||||
}),
|
||||
getCredentials: async () => ({ token: 'token' }),
|
||||
signOut: async () => {},
|
||||
};
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn(() => {
|
||||
throw new Error('sign-in bootstrap failed');
|
||||
}),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
let onSignInSuccess: ((identity: IdentityApi) => void) | undefined;
|
||||
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
function SignInPage(props: {
|
||||
onSignInSuccess(identity: IdentityApi): void;
|
||||
}) {
|
||||
onSignInSuccess = props.onSignInSuccess;
|
||||
|
||||
return <div>Custom Sign In</div>;
|
||||
}
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Flagged Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Custom Sign In'),
|
||||
).resolves.toBeInTheDocument();
|
||||
if (!onSignInSuccess) {
|
||||
throw new Error('Expected sign-in success callback to be captured');
|
||||
}
|
||||
const triggerSignInSuccess = onSignInSuccess;
|
||||
act(() => {
|
||||
triggerSignInSuccess(identityApi);
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('sign-in bootstrap failed'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should deduplicate features keeping the last received one', async () => {
|
||||
const duplicatedFeatureId = 'test';
|
||||
const app = createApp({
|
||||
@@ -283,47 +495,497 @@ describe('createApp', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should warn about unknown extension config', async () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
it('should evaluate extension if predicates before rendering apps without sign-in', async () => {
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn((name: string) => name === 'test-flag'),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Derp</div>,
|
||||
loader: async () => <div>Flagged Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
advanced: {
|
||||
configLoader: async () => ({
|
||||
config: mockApis.config({
|
||||
data: {
|
||||
app: {
|
||||
extensions: [{ 'unknown:lols/wut': false }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
expect(warnSpy).toHaveBeenCalledWith('App startup encountered warnings:');
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'INVALID_EXTENSION_CONFIG_KEY: Extension unknown:lols/wut does not exist',
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
await expect(
|
||||
screen.findByText('Flagged Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag');
|
||||
});
|
||||
|
||||
it('should support $all feature flag predicates on pages', async () => {
|
||||
const partialFlagsApi = createFeatureFlagsApi(['experimental-features']);
|
||||
const partialFlagsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => partialFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'advanced-features' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>All Flags Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const partialRender = await renderWithEffects(partialFlagsApp.createRoot());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('All Flags Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
partialRender.unmount();
|
||||
|
||||
const allFlagsApi = createFeatureFlagsApi([
|
||||
'experimental-features',
|
||||
'advanced-features',
|
||||
]);
|
||||
const allFlagsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => allFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'advanced-features' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>All Flags Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(allFlagsApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('All Flags Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(allFlagsApi.isActive).toHaveBeenCalledWith('experimental-features');
|
||||
expect(allFlagsApi.isActive).toHaveBeenCalledWith('advanced-features');
|
||||
});
|
||||
|
||||
it('should support $any feature flag predicates on pages', async () => {
|
||||
const noFlagsApi = createFeatureFlagsApi([]);
|
||||
const noFlagsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => noFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'beta-access' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Any Flag Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const noFlagsRender = await renderWithEffects(noFlagsApp.createRoot());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Any Flag Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
noFlagsRender.unmount();
|
||||
|
||||
const oneFlagApi = createFeatureFlagsApi(['beta-access']);
|
||||
const oneFlagApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => oneFlagApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'beta-access' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Any Flag Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(oneFlagApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Any Flag Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(oneFlagApi.isActive).toHaveBeenCalledWith('experimental-features');
|
||||
expect(oneFlagApi.isActive).toHaveBeenCalledWith('beta-access');
|
||||
});
|
||||
|
||||
it('should support permission predicates on pages', async () => {
|
||||
const deniedPermissionApi = createPermissionApi([]);
|
||||
const deniedApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => deniedPermissionApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Permission Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const deniedRender = await renderWithEffects(deniedApp.createRoot());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Permission Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
deniedRender.unmount();
|
||||
|
||||
const allowedPermissionApi = createPermissionApi(['catalog.entity.create']);
|
||||
const allowedApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => allowedPermissionApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Permission Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(allowedApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Permission Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(allowedPermissionApi.authorize).toHaveBeenCalledWith({
|
||||
permission: {
|
||||
name: 'catalog.entity.create',
|
||||
type: 'basic',
|
||||
attributes: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should support conditional child extensions attached to pages', async () => {
|
||||
const CardBlueprint = createExtensionBlueprint({
|
||||
kind: 'card',
|
||||
attachTo: { id: 'page:test/card-page', input: 'cards' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
*factory(params: { title: string }) {
|
||||
yield coreExtensionData.reactElement(<div>{params.title}</div>);
|
||||
},
|
||||
});
|
||||
|
||||
const page = PageBlueprint.makeWithOverrides({
|
||||
name: 'card-page',
|
||||
inputs: {
|
||||
cards: createExtensionInput([coreExtensionData.reactElement], {
|
||||
optional: false,
|
||||
singleton: false,
|
||||
}),
|
||||
},
|
||||
factory(originalFactory, { inputs }) {
|
||||
return originalFactory({
|
||||
path: '/',
|
||||
loader: async () => (
|
||||
<div>
|
||||
{inputs.cards.map(card =>
|
||||
card.get(coreExtensionData.reactElement),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const publicCard = CardBlueprint.make({
|
||||
name: 'public',
|
||||
params: { title: 'Public Card' },
|
||||
});
|
||||
const permissionCard = CardBlueprint.make({
|
||||
name: 'permission',
|
||||
params: { title: 'Permission Card' },
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
const featureFlagCard = CardBlueprint.make({
|
||||
name: 'feature-flag',
|
||||
params: { title: 'Feature Flag Card' },
|
||||
if: { featureFlags: { $contains: 'experimental-card' } },
|
||||
});
|
||||
|
||||
const hiddenCardsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'permission-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => createPermissionApi([]),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'feature-flags-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => createFeatureFlagsApi([]),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'experimental-card' }],
|
||||
extensions: [page, publicCard, permissionCard, featureFlagCard],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const hiddenCardsRender = await renderWithEffects(
|
||||
hiddenCardsApp.createRoot(),
|
||||
);
|
||||
await expect(screen.findByText('Public Card')).resolves.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Permission Card')).not.toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Feature Flag Card')).not.toBeInTheDocument(),
|
||||
);
|
||||
hiddenCardsRender.unmount();
|
||||
|
||||
const visibleCardsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'permission-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => createPermissionApi(['catalog.entity.create']),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'feature-flags-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => createFeatureFlagsApi(['experimental-card']),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'experimental-card' }],
|
||||
extensions: [page, publicCard, permissionCard, featureFlagCard],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(visibleCardsApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Permission Card'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Feature Flag Card'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should make the app structure available through the AppTreeApi', async () => {
|
||||
let appTreeApi: AppTreeApi | undefined = undefined;
|
||||
|
||||
@@ -430,9 +1092,6 @@ describe('createApp', () => {
|
||||
<app-root-element:app/alert-display out=[core.reactElement] />
|
||||
<app-root-element:app/dialog-display out=[core.reactElement] />
|
||||
]
|
||||
signInPage [
|
||||
<sign-in-page:app />
|
||||
]
|
||||
</app/root>
|
||||
]
|
||||
</app>
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JSX, lazy, ReactNode, Suspense } from 'react';
|
||||
import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react';
|
||||
import {
|
||||
ConfigApi,
|
||||
coreExtensionData,
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
FrontendFeatureLoader,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
@@ -29,8 +29,9 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
CreateAppRouteBinder,
|
||||
createSpecializedApp,
|
||||
ExtensionFactoryMiddleware,
|
||||
FinalizedSpecializedApp,
|
||||
prepareSpecializedApp,
|
||||
PreparedSpecializedApp,
|
||||
FrontendPluginInfoResolver,
|
||||
} from '@backstage/frontend-app-api';
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
@@ -119,23 +120,16 @@ export function createApp(options?: CreateAppOptions): {
|
||||
features: [...discoveredFeaturesAndLoaders, ...(options?.features ?? [])],
|
||||
});
|
||||
|
||||
const app = createSpecializedApp({
|
||||
const preparedApp = prepareSpecializedApp({
|
||||
features: [appPlugin, ...loadedFeatures],
|
||||
config,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
advanced: options?.advanced,
|
||||
});
|
||||
|
||||
const errorPage = maybeCreateErrorPage(app);
|
||||
if (errorPage) {
|
||||
return { default: () => errorPage };
|
||||
}
|
||||
|
||||
const rootEl = app.tree.root.instance!.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
|
||||
return { default: () => rootEl };
|
||||
return {
|
||||
default: () => <PreparedAppRoot preparedApp={preparedApp} />,
|
||||
};
|
||||
}
|
||||
|
||||
const LazyApp = lazy(appLoader);
|
||||
@@ -150,3 +144,28 @@ export function createApp(options?: CreateAppOptions): {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function PreparedAppRoot(props: {
|
||||
preparedApp: PreparedSpecializedApp;
|
||||
}): JSX.Element {
|
||||
const bootstrapApp = props.preparedApp.getBootstrapApp();
|
||||
const [finalizedApp, setFinalizedApp] = useState<
|
||||
FinalizedSpecializedApp | undefined
|
||||
>();
|
||||
|
||||
useEffect(
|
||||
() => props.preparedApp.onFinalized(setFinalizedApp),
|
||||
[props.preparedApp],
|
||||
);
|
||||
|
||||
if (!finalizedApp) {
|
||||
return bootstrapApp.element;
|
||||
}
|
||||
|
||||
const errorPage = maybeCreateErrorPage(finalizedApp);
|
||||
if (errorPage) {
|
||||
return errorPage;
|
||||
}
|
||||
|
||||
return finalizedApp.element;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ const DEFAULT_WARNING_CODES: Array<keyof AppErrorTypes> = [
|
||||
'EXTENSION_INPUT_DATA_IGNORED',
|
||||
'EXTENSION_INPUT_INTERNAL_IGNORED',
|
||||
'EXTENSION_OUTPUT_IGNORED',
|
||||
'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED',
|
||||
'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
|
||||
];
|
||||
|
||||
function AppErrorItem(props: { error: AppError }): JSX.Element {
|
||||
|
||||
Reference in New Issue
Block a user