diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json
index e6b6acae3a..ef5d924200 100644
--- a/plugins/catalog-react/package.json
+++ b/plugins/catalog-react/package.json
@@ -29,7 +29,7 @@
"sideEffects": false,
"exports": {
".": "./src/index.ts",
- "./alpha": "./src/alpha.tsx",
+ "./alpha": "./src/alpha/index.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
@@ -37,7 +37,7 @@
"typesVersions": {
"*": {
"alpha": [
- "src/alpha.tsx"
+ "src/alpha/index.ts"
],
"package.json": [
"package.json"
@@ -85,6 +85,7 @@
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
+ "@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/test-utils": "workspace:^",
diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx
new file mode 100644
index 0000000000..db787766fd
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2024 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 React from 'react';
+import { EntityCardBlueprint } from './EntityCardBlueprint';
+import {
+ coreExtensionData,
+ createExtension,
+ createExtensionInput,
+} from '@backstage/frontend-plugin-api';
+import { createExtensionTester } from '@backstage/frontend-test-utils';
+import { waitFor, screen } from '@testing-library/react';
+import { Entity } from '@backstage/catalog-model';
+
+describe('EntityCardBlueprint', () => {
+ it('should return an extension with sensible defaults', () => {
+ const extension = EntityCardBlueprint.make({
+ name: 'test',
+ params: {
+ filter: 'has:labels',
+ loader: async () =>
im a card
,
+ },
+ });
+
+ expect(extension).toMatchInlineSnapshot(`
+ {
+ "$$type": "@backstage/ExtensionDefinition",
+ "attachTo": {
+ "id": "entity-content:catalog/overview",
+ "input": "cards",
+ },
+ "configSchema": {
+ "parse": [Function],
+ "schema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "additionalProperties": false,
+ "properties": {
+ "filter": {
+ "type": "string",
+ },
+ },
+ "type": "object",
+ },
+ },
+ "disabled": false,
+ "factory": [Function],
+ "inputs": {},
+ "kind": "entity-card",
+ "name": "test",
+ "namespace": undefined,
+ "output": [
+ [Function],
+ {
+ "$$type": "@backstage/ExtensionDataRef",
+ "config": {
+ "optional": true,
+ },
+ "id": "catalog.entity-filter-function",
+ "optional": [Function],
+ "toString": [Function],
+ },
+ {
+ "$$type": "@backstage/ExtensionDataRef",
+ "config": {
+ "optional": true,
+ },
+ "id": "catalog.entity-filter-expression",
+ "optional": [Function],
+ "toString": [Function],
+ },
+ ],
+ "override": [Function],
+ "toString": [Function],
+ "version": "v2",
+ }
+ `);
+ });
+
+ it('should output the correct filter output', () => {
+ const mockFilter = (_entity: Entity) => true;
+
+ expect(
+ createExtensionTester(
+ EntityCardBlueprint.make({
+ name: 'test',
+ params: {
+ loader: async () => Test!
,
+ filter: 'test',
+ },
+ }),
+ ).data(EntityCardBlueprint.dataRefs.filterExpression),
+ ).toBe('test');
+
+ expect(
+ createExtensionTester(
+ EntityCardBlueprint.make({
+ name: 'test',
+ params: {
+ loader: async () => Test!
,
+ },
+ }),
+ { config: { filter: 'test' } },
+ ).data(EntityCardBlueprint.dataRefs.filterExpression),
+ ).toBe('test');
+
+ expect(
+ createExtensionTester(
+ EntityCardBlueprint.make({
+ name: 'test',
+ params: {
+ filter: mockFilter,
+ loader: async () => Test!
,
+ },
+ }),
+ ).data(EntityCardBlueprint.dataRefs.filterFunction),
+ ).toBe(mockFilter);
+ });
+
+ it('should allow overriding config and inputs', async () => {
+ const extension = EntityCardBlueprint.makeWithOverrides({
+ name: 'test',
+ inputs: {
+ mock: createExtensionInput([coreExtensionData.reactElement]),
+ },
+ config: {
+ schema: {
+ mock: z => z.string(),
+ },
+ },
+ factory(originalFactory, { inputs, config }) {
+ return originalFactory({
+ loader: async () => (
+
+ config: {config.mock}
+
+ {inputs.mock.map((i, k) => (
+
{i.get(coreExtensionData.reactElement)}
+ ))}
+
+
+ ),
+ });
+ },
+ });
+
+ const mockExtension = createExtension({
+ attachTo: { id: 'entity-card:test', input: 'mock' },
+ output: [coreExtensionData.reactElement],
+ factory() {
+ return [coreExtensionData.reactElement(im a mock
)];
+ },
+ });
+
+ createExtensionTester(extension, { config: { mock: 'mock test config' } })
+ .add(mockExtension)
+ .render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('test')).toBeInTheDocument();
+ expect(screen.getByTestId('test')).toHaveTextContent(
+ 'config: mock test config',
+ );
+ expect(screen.getByTestId('contents')).toHaveTextContent('im a mock');
+ });
+ });
+});
diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.tsx
new file mode 100644
index 0000000000..c3153b6059
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 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 React, { lazy } from 'react';
+import {
+ ExtensionBoundary,
+ coreExtensionData,
+ createExtensionBlueprint,
+} from '@backstage/frontend-plugin-api';
+import { catalogExtensionData } from '../extensions';
+
+/**
+ * @alpha
+ * A blueprint for creating cards for the entity pages in the catalog.
+ */
+export const EntityCardBlueprint = createExtensionBlueprint({
+ kind: 'entity-card',
+ attachTo: { id: 'entity-content:catalog/overview', input: 'cards' },
+ output: [
+ coreExtensionData.reactElement,
+ catalogExtensionData.entityFilterFunction.optional(),
+ catalogExtensionData.entityFilterExpression.optional(),
+ ],
+ dataRefs: {
+ filterFunction: catalogExtensionData.entityFilterFunction,
+ filterExpression: catalogExtensionData.entityFilterExpression,
+ },
+ config: {
+ schema: {
+ filter: z => z.string().optional(),
+ },
+ },
+ *factory(
+ {
+ loader,
+ filter,
+ }: {
+ loader: () => Promise;
+ filter?:
+ | typeof catalogExtensionData.entityFilterFunction.T
+ | typeof catalogExtensionData.entityFilterExpression.T;
+ },
+ { node, config },
+ ) {
+ const ExtensionComponent = lazy(() =>
+ loader().then(element => ({ default: () => element })),
+ );
+
+ yield coreExtensionData.reactElement(
+
+
+ ,
+ );
+
+ if (config.filter) {
+ yield catalogExtensionData.entityFilterExpression(config.filter);
+ } else if (typeof filter === 'string') {
+ yield catalogExtensionData.entityFilterExpression(filter);
+ } else if (typeof filter === 'function') {
+ yield catalogExtensionData.entityFilterFunction(filter);
+ }
+ },
+});
diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx
new file mode 100644
index 0000000000..dab6013bab
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2024 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 React from 'react';
+import { EntityContentBlueprint } from './EntityContentBlueprint';
+import { createExtensionTester } from '@backstage/frontend-test-utils';
+import {
+ coreExtensionData,
+ createExtension,
+ createExtensionInput,
+ createRouteRef,
+} from '@backstage/frontend-plugin-api';
+import { Entity } from '@backstage/catalog-model';
+import { waitFor, screen } from '@testing-library/react';
+
+describe('EntityContentBlueprint', () => {
+ it('should return an extension with sane defaults', () => {
+ const extension = EntityContentBlueprint.make({
+ name: 'test',
+ params: {
+ defaultPath: '/test',
+ defaultTitle: 'Test',
+ loader: async () => Test!
,
+ },
+ });
+
+ expect(extension).toMatchInlineSnapshot(`
+ {
+ "$$type": "@backstage/ExtensionDefinition",
+ "attachTo": {
+ "id": "page:catalog/entity",
+ "input": "contents",
+ },
+ "configSchema": {
+ "parse": [Function],
+ "schema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "additionalProperties": false,
+ "properties": {
+ "filter": {
+ "type": "string",
+ },
+ "path": {
+ "type": "string",
+ },
+ "title": {
+ "type": "string",
+ },
+ },
+ "type": "object",
+ },
+ },
+ "disabled": false,
+ "factory": [Function],
+ "inputs": {},
+ "kind": "entity-content",
+ "name": "test",
+ "namespace": undefined,
+ "output": [
+ [Function],
+ [Function],
+ [Function],
+ {
+ "$$type": "@backstage/ExtensionDataRef",
+ "config": {
+ "optional": true,
+ },
+ "id": "core.routing.ref",
+ "optional": [Function],
+ "toString": [Function],
+ },
+ {
+ "$$type": "@backstage/ExtensionDataRef",
+ "config": {
+ "optional": true,
+ },
+ "id": "catalog.entity-filter-function",
+ "optional": [Function],
+ "toString": [Function],
+ },
+ {
+ "$$type": "@backstage/ExtensionDataRef",
+ "config": {
+ "optional": true,
+ },
+ "id": "catalog.entity-filter-expression",
+ "optional": [Function],
+ "toString": [Function],
+ },
+ ],
+ "override": [Function],
+ "toString": [Function],
+ "version": "v2",
+ }
+ `);
+ });
+
+ it('should emit the correct defaults', () => {
+ const mockRouteRef = createRouteRef();
+ const extension = EntityContentBlueprint.make({
+ name: 'test',
+ params: {
+ defaultPath: '/test',
+ defaultTitle: 'Test',
+ routeRef: mockRouteRef,
+ loader: async () => Test!
,
+ },
+ });
+
+ const tester = createExtensionTester(extension);
+
+ // todo(blam): route paths are always set to / in the createExtensionTester. This will work eventually.
+ // expect(tester.data(coreExtensionData.routePath)).toBe('/test');
+
+ expect(tester.data(coreExtensionData.routeRef)).toBe(mockRouteRef);
+ expect(tester.data(EntityContentBlueprint.dataRefs.title)).toBe('Test');
+ });
+
+ it('should emit the correct filter output', () => {
+ const mockFilter = (_entity: Entity) => true;
+
+ expect(
+ createExtensionTester(
+ EntityContentBlueprint.make({
+ name: 'test',
+ params: {
+ defaultPath: '/test',
+ defaultTitle: 'Test',
+ loader: async () => Test!
,
+ filter: 'test',
+ },
+ }),
+ ).data(EntityContentBlueprint.dataRefs.filterExpression),
+ ).toBe('test');
+
+ expect(
+ createExtensionTester(
+ EntityContentBlueprint.make({
+ name: 'test',
+ params: {
+ defaultPath: '/test',
+ defaultTitle: 'Test',
+ loader: async () => Test!
,
+ },
+ }),
+ { config: { filter: 'test' } },
+ ).data(EntityContentBlueprint.dataRefs.filterExpression),
+ ).toBe('test');
+
+ expect(
+ createExtensionTester(
+ EntityContentBlueprint.make({
+ name: 'test',
+ params: {
+ defaultPath: '/test',
+ defaultTitle: 'Test',
+ filter: mockFilter,
+ loader: async () => Test!
,
+ },
+ }),
+ ).data(EntityContentBlueprint.dataRefs.filterFunction),
+ ).toBe(mockFilter);
+ });
+
+ it('should allow overriding config and inputs', async () => {
+ const extension = EntityContentBlueprint.makeWithOverrides({
+ name: 'test',
+ inputs: {
+ mock: createExtensionInput([coreExtensionData.reactElement]),
+ },
+ config: {
+ schema: {
+ mock: z => z.string(),
+ },
+ },
+ factory(originalFactory, { inputs, config }) {
+ return originalFactory({
+ defaultPath: '/test',
+ defaultTitle: 'Test',
+ loader: async () => (
+
+ config: {config.mock}
+
+ {inputs.mock.map((i, k) => (
+
{i.get(coreExtensionData.reactElement)}
+ ))}
+
+
+ ),
+ });
+ },
+ });
+
+ const mockExtension = createExtension({
+ attachTo: { id: 'entity-content:test', input: 'mock' },
+ output: [coreExtensionData.reactElement],
+ factory() {
+ return [coreExtensionData.reactElement(im a mock
)];
+ },
+ });
+
+ createExtensionTester(extension, { config: { mock: 'mock test config' } })
+ .add(mockExtension)
+ .render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('test')).toBeInTheDocument();
+ expect(screen.getByTestId('test')).toHaveTextContent(
+ 'config: mock test config',
+ );
+ expect(screen.getByTestId('contents')).toHaveTextContent('im a mock');
+ });
+ });
+});
diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.tsx
new file mode 100644
index 0000000000..7c7a6c43b7
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.tsx
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024 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 React, { lazy } from 'react';
+import {
+ coreExtensionData,
+ createExtensionBlueprint,
+ ExtensionBoundary,
+ RouteRef,
+} from '@backstage/frontend-plugin-api';
+import { catalogExtensionData } from '../extensions';
+
+/**
+ * @alpha
+ * Creates an EntityContent extension.
+ */
+export const EntityContentBlueprint = createExtensionBlueprint({
+ kind: 'entity-content',
+ attachTo: { id: 'page:catalog/entity', input: 'contents' },
+ output: [
+ coreExtensionData.reactElement,
+ coreExtensionData.routePath,
+ catalogExtensionData.entityContentTitle,
+ coreExtensionData.routeRef.optional(),
+ catalogExtensionData.entityFilterFunction.optional(),
+ catalogExtensionData.entityFilterExpression.optional(),
+ ],
+ dataRefs: {
+ title: catalogExtensionData.entityContentTitle,
+ filterFunction: catalogExtensionData.entityFilterFunction,
+ filterExpression: catalogExtensionData.entityFilterExpression,
+ },
+ config: {
+ schema: {
+ path: z => z.string().optional(),
+ title: z => z.string().optional(),
+ filter: z => z.string().optional(),
+ },
+ },
+ *factory(
+ {
+ loader,
+ defaultPath,
+ defaultTitle,
+ filter,
+ routeRef,
+ }: {
+ loader: () => Promise;
+ defaultPath: string;
+ defaultTitle: string;
+ routeRef?: RouteRef;
+ filter?:
+ | typeof catalogExtensionData.entityFilterFunction.T
+ | typeof catalogExtensionData.entityFilterExpression.T;
+ },
+ { node, config },
+ ) {
+ const path = config.path ?? defaultPath;
+ const title = config.title ?? defaultTitle;
+
+ const ExtensionComponent = lazy(() =>
+ loader().then(element => ({ default: () => element })),
+ );
+
+ yield coreExtensionData.reactElement(
+
+
+ ,
+ );
+
+ yield coreExtensionData.routePath(path);
+
+ yield catalogExtensionData.entityContentTitle(title);
+
+ if (routeRef) {
+ yield coreExtensionData.routeRef(routeRef);
+ }
+
+ if (config.filter) {
+ yield catalogExtensionData.entityFilterExpression(config.filter);
+ } else if (typeof filter === 'string') {
+ yield catalogExtensionData.entityFilterExpression(filter);
+ } else if (typeof filter === 'function') {
+ yield catalogExtensionData.entityFilterFunction(filter);
+ }
+ },
+});
diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts
new file mode 100644
index 0000000000..dccaf28cd9
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/blueprints/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2024 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.
+ */
+export { EntityCardBlueprint } from './EntityCardBlueprint';
+export { EntityContentBlueprint } from './EntityContentBlueprint';
diff --git a/plugins/catalog-react/src/alpha.tsx b/plugins/catalog-react/src/alpha/extensions.tsx
similarity index 93%
rename from plugins/catalog-react/src/alpha.tsx
rename to plugins/catalog-react/src/alpha/extensions.tsx
index 42d69726b6..fa91494bd4 100644
--- a/plugins/catalog-react/src/alpha.tsx
+++ b/plugins/catalog-react/src/alpha/extensions.tsx
@@ -28,13 +28,16 @@ import {
import React, { lazy } from 'react';
import { Entity } from '@backstage/catalog-model';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
-import { Expand } from '../../../packages/frontend-plugin-api/src/types';
+import { Expand } from '../../../../packages/frontend-plugin-api/src/types';
-export { useEntityPermission } from './hooks/useEntityPermission';
-export { isOwnerOf } from './utils';
-export * from './translation';
+export { useEntityPermission } from '../hooks/useEntityPermission';
+export { isOwnerOf } from '../utils';
+export * from '../translation';
-/** @alpha */
+/**
+ * @alpha
+ * @deprecated use `dataRefs` on the blueprints instead
+ */
export const catalogExtensionData = {
entityContentTitle: createExtensionDataRef().with({
id: 'catalog.entity-content-title',
@@ -48,7 +51,10 @@ export const catalogExtensionData = {
};
// TODO: Figure out how to merge with provided config schema
-/** @alpha */
+/**
+ * @alpha
+ * @deprecated use {@link EntityCardBlueprint} instead
+ */
export function createEntityCardExtension<
TConfig extends { filter?: string },
TInputs extends AnyExtensionInputMap,
@@ -110,7 +116,10 @@ export function createEntityCardExtension<
});
}
-/** @alpha */
+/**
+ * @alpha
+ * @deprecated use {@link EntityContentBlueprint} instead
+ */
export function createEntityContentExtension<
TInputs extends AnyExtensionInputMap,
>(options: {
diff --git a/plugins/catalog-react/src/alpha/index.ts b/plugins/catalog-react/src/alpha/index.ts
new file mode 100644
index 0000000000..132b288008
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2024 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.
+ */
+export * from './blueprints';
+export * from './extensions';
diff --git a/plugins/catalog-react/src/setupTests.ts b/plugins/catalog-react/src/setupTests.ts
index 963c0f188b..dae4360fd3 100644
--- a/plugins/catalog-react/src/setupTests.ts
+++ b/plugins/catalog-react/src/setupTests.ts
@@ -15,3 +15,18 @@
*/
import '@testing-library/jest-dom';
+
+// eslint-disable-next-line no-console
+const originalConsoleWarn = console.warn;
+// eslint-disable-next-line no-console
+console.warn = (...args: any[]) => {
+ const message = args[0];
+ if (
+ typeof message === 'string' &&
+ (message.includes('CSSOM.parse is not a function') ||
+ message.includes('[JSS]'))
+ ) {
+ return;
+ }
+ originalConsoleWarn(...args);
+};