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:
Patrik Oldsberg
2026-03-17 12:28:51 +01:00
190 changed files with 8385 additions and 978 deletions
@@ -39,6 +39,7 @@ describe('AnalyticsBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "analytics",
"name": "test",
@@ -43,6 +43,7 @@ describe('AppRootWrapperBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "app-root-wrapper",
"name": undefined,
@@ -86,6 +86,7 @@ describe('NavContentBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "nav-content",
"name": undefined,
@@ -42,6 +42,7 @@ describe('RouterBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "app-router-component",
"name": undefined,
@@ -38,6 +38,7 @@ describe('SignInPageBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "sign-in-page",
"name": undefined,
@@ -39,6 +39,7 @@ describe('ThemeBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "theme",
"name": "light",
@@ -54,6 +54,7 @@ describe('TranslationBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "translation",
"name": "blob",
+1
View File
@@ -53,6 +53,7 @@
"dependencies": {
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-app-react": "workspace:^",
+1 -1
View File
@@ -147,7 +147,7 @@ const appPlugin: OverridableFrontendPlugin<
ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>,
{
singleton: true;
optional: false;
optional: true;
internal: false;
}
>;
+43 -39
View File
@@ -22,6 +22,7 @@ import {
JSX,
} from 'react';
import {
ExtensionBoundary,
coreExtensionData,
discoveryApiRef,
fetchApiRef,
@@ -73,6 +74,7 @@ export const AppRoot = createExtension({
}),
children: createExtensionInput([coreExtensionData.reactElement], {
singleton: true,
optional: true,
}),
elements: createExtensionInput([coreExtensionData.reactElement]),
wrappers: createExtensionInput(
@@ -83,7 +85,7 @@ export const AppRoot = createExtension({
),
},
output: [coreExtensionData.reactElement],
factory({ inputs, apis }) {
factory({ inputs, apis, node }) {
if (isProtectedApp()) {
const identityApi = apis.get(identityApiRef);
if (!identityApi) {
@@ -105,9 +107,7 @@ export const AppRoot = createExtension({
});
}
let content: ReactNode = inputs.children.get(
coreExtensionData.reactElement,
);
let content = inputs.children?.get(coreExtensionData.reactElement);
for (const wrapper of inputs.wrappers) {
const Component = wrapper.get(AppRootWrapperBlueprint.dataRefs.component);
@@ -124,19 +124,21 @@ export const AppRoot = createExtension({
return [
coreExtensionData.reactElement(
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>,
<ExtensionBoundary node={node}>
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>
</ExtensionBoundary>,
),
];
},
@@ -253,28 +255,30 @@ export function AppRouter(props: AppRouterProps) {
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl: basePath || '/' },
);
if (!isProtectedApp()) {
appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl: basePath || '/' },
);
}
return (
<RouterComponent>
@@ -43,6 +43,7 @@ describe('CatalogFilterBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "catalog-filter",
"name": undefined,
@@ -200,6 +200,7 @@ describe('EntityCardBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "entity-card",
"name": "test",
@@ -215,6 +215,7 @@ describe('EntityContentBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "entity-content",
"name": "test",
@@ -204,6 +204,7 @@ describe('EntityContextMenuItemBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "entity-context-menu-item",
"name": "test",
+1
View File
@@ -45,6 +45,7 @@
"@backstage/config": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"dataloader": "^2.0.0",
"swr": "^2.0.0"
},
"devDependencies": {
@@ -0,0 +1,97 @@
/*
* Copyright 2026 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 { ConfigReader } from '@backstage/config';
import { mockApis } from '@backstage/test-utils';
import {
createPermission,
PermissionClient,
} from '@backstage/plugin-permission-common';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { IdentityPermissionApi } from './IdentityPermissionApi';
describe('IdentityPermissionApi', () => {
const permission = createPermission({
name: 'test.permission',
attributes: {},
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should batch requests that arrive on the same tick', async () => {
const authorizeSpy = jest
.spyOn(PermissionClient.prototype, 'authorize')
.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
{ result: AuthorizeResult.DENY },
]);
const api = IdentityPermissionApi.create({
config: new ConfigReader({}),
discovery: mockApis.discovery(),
identity: mockApis.identity(),
});
const firstRequest = { permission };
const secondRequest = { permission };
const [firstResponse, secondResponse] = await Promise.all([
api.authorize(firstRequest),
api.authorize(secondRequest),
]);
expect(firstResponse.result).toBe(AuthorizeResult.ALLOW);
expect(secondResponse.result).toBe(AuthorizeResult.DENY);
expect(authorizeSpy).toHaveBeenCalledTimes(1);
expect(authorizeSpy).toHaveBeenCalledWith(
[firstRequest, secondRequest],
expect.anything(),
);
});
it('should not cache requests across ticks', async () => {
const authorizeSpy = jest
.spyOn(PermissionClient.prototype, 'authorize')
.mockResolvedValue([{ result: AuthorizeResult.ALLOW }]);
const identityApi = mockApis.identity();
const credentialsSpy = jest
.spyOn(identityApi, 'getCredentials')
.mockResolvedValueOnce({ token: 'first-token' })
.mockResolvedValueOnce({ token: 'second-token' });
const api = IdentityPermissionApi.create({
config: new ConfigReader({}),
discovery: mockApis.discovery(),
identity: identityApi,
});
const request = { permission };
await api.authorize(request);
await api.authorize(request);
expect(authorizeSpy).toHaveBeenCalledTimes(2);
expect(credentialsSpy).toHaveBeenCalledTimes(2);
expect(authorizeSpy).toHaveBeenNthCalledWith(
1,
[request],
expect.objectContaining({ token: 'first-token' }),
);
expect(authorizeSpy).toHaveBeenNthCalledWith(
2,
[request],
expect.objectContaining({ token: 'second-token' }),
);
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import DataLoader from 'dataloader';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { PermissionApi } from './PermissionApi';
import {
@@ -24,20 +25,30 @@ import {
import { Config } from '@backstage/config';
/**
* The default implementation of the PermissionApi, which simply calls the authorize method of the given
* {@link @backstage/plugin-permission-common#PermissionClient}.
* The default implementation of the PermissionApi, which batches calls to
* {@link @backstage/plugin-permission-common#PermissionClient} that are made
* within the same microtask into a single HTTP request.
* @public
*/
export class IdentityPermissionApi implements PermissionApi {
private readonly permissionClient: PermissionClient;
private readonly identityApi: IdentityApi;
private readonly loader: DataLoader<
AuthorizePermissionRequest,
AuthorizePermissionResponse
>;
private constructor(
permissionClient: PermissionClient,
identityApi: IdentityApi,
) {
this.permissionClient = permissionClient;
this.identityApi = identityApi;
this.loader = new DataLoader(
async (requests: readonly AuthorizePermissionRequest[]) => {
const credentials = await identityApi.getCredentials();
return permissionClient.authorize([...requests], credentials);
},
{
cache: false,
},
);
}
static create(options: {
@@ -50,13 +61,12 @@ export class IdentityPermissionApi implements PermissionApi {
return new IdentityPermissionApi(permissionClient, identity);
}
async authorize(
request: AuthorizePermissionRequest,
): Promise<AuthorizePermissionResponse>;
async authorize(
request: AuthorizePermissionRequest,
): Promise<AuthorizePermissionResponse> {
const response = await this.permissionClient.authorize(
[request],
await this.identityApi.getCredentials(),
);
return response[0];
return await this.loader.load(request);
}
}
@@ -154,7 +154,7 @@ export const createGitlabRepoPushAction: (options: {
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined;
commitAction?: 'auto' | 'update' | 'create' | 'delete' | undefined;
},
{
projectid: string;
@@ -271,7 +271,7 @@ export const createPublishGitlabMergeRequestAction: (options: {
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined;
commitAction?: 'auto' | 'update' | 'create' | 'delete' | 'skip' | undefined;
projectid?: string | undefined;
removeSourceBranch?: boolean | undefined;
assignee?: string | undefined;
+3 -1
View File
@@ -200,7 +200,9 @@ export type FormFieldExtensionData<
};
// @alpha (undocumented)
export const formFieldsApiRef: ApiRef<ScaffolderFormFieldsApi>;
export const formFieldsApiRef: ApiRef<ScaffolderFormFieldsApi> & {
readonly $$type: '@backstage/ApiRef';
};
// @alpha (undocumented)
export type FormValidation = {
+3 -1
View File
@@ -208,7 +208,9 @@ export type ReviewStepProps = {
export type ScaffolderApi = ScaffolderApi_2;
// @public (undocumented)
export const scaffolderApiRef: ApiRef<ScaffolderApi_2>;
export const scaffolderApiRef: ApiRef<ScaffolderApi_2> & {
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated (undocumented)
export type ScaffolderDryRunOptions = ScaffolderDryRunOptions_2;
+3 -1
View File
@@ -479,7 +479,9 @@ export const formDecoratorsApi: OverridableExtensionDefinition<{
}>;
// @alpha (undocumented)
export const formDecoratorsApiRef: ApiRef<ScaffolderFormDecoratorsApi>;
export const formDecoratorsApiRef: ApiRef<ScaffolderFormDecoratorsApi> & {
readonly $$type: '@backstage/ApiRef';
};
export { formFieldsApiRef };
+3 -1
View File
@@ -528,7 +528,9 @@ export type RouterProps = {
export type ScaffolderApi = ScaffolderApi_2;
// @public @deprecated (undocumented)
export const scaffolderApiRef: ApiRef<ScaffolderApi_2>;
export const scaffolderApiRef: ApiRef<ScaffolderApi_2> & {
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated
export class ScaffolderClient extends ScaffolderClient_2 {}
@@ -45,6 +45,7 @@ describe('SearchFilterBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "search-filter",
"name": "test",
@@ -47,6 +47,7 @@ describe('SearchFilterResultTypeBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "search-filter-result-type",
"name": "test",
@@ -60,6 +60,7 @@ describe('SearchResultListItemBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "search-result-list-item",
"name": "test",