stitch targetRef and backwards compat inject it on read

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2022-02-18 13:57:58 +01:00
parent 4cca190797
commit d0582b2d0f
64 changed files with 388 additions and 143 deletions
+1
View File
@@ -231,6 +231,7 @@ export type EntityRefContext = {
export type EntityRelation = {
type: string;
target: EntityName;
targetRef: string;
};
// @public
@@ -66,7 +66,8 @@ describe('DefaultApiExplorerPage', () => {
relations: [
{
type: RELATION_MEMBER_OF,
target: { namespace: 'default', kind: 'Group', name: 'tools' },
targetRef: 'group:default/tools',
target: { namespace: 'default', kind: 'group', name: 'tools' },
},
],
};
@@ -95,10 +95,11 @@ describe('<ConsumedApisCard />', () => {
relations: [
{
target: {
kind: 'API',
kind: 'api',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'api:my-namespace/target-name',
type: RELATION_CONSUMES_API,
},
],
@@ -95,10 +95,11 @@ describe('<HasApisCard />', () => {
relations: [
{
target: {
kind: 'API',
kind: 'api',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'api:my-namespace/target-name',
type: RELATION_HAS_PART,
},
],
@@ -95,10 +95,11 @@ describe('<ProvidedApisCard />', () => {
relations: [
{
target: {
kind: 'API',
kind: 'api',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'api:my-namespace/target-name',
type: RELATION_PROVIDES_API,
},
],
@@ -98,10 +98,11 @@ describe('<ConsumingComponentsCard />', () => {
relations: [
{
target: {
kind: 'Component',
kind: 'component',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'component:my-namespace/target-name',
type: RELATION_API_CONSUMED_BY,
},
],
@@ -98,10 +98,11 @@ describe('<ProvidingComponentsCard />', () => {
relations: [
{
target: {
kind: 'Component',
kind: 'component',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'component:my-namespace/target-name',
type: RELATION_API_PROVIDED_BY,
},
],
@@ -79,8 +79,9 @@ describe('CatalogIdentityClient', () => {
relations: [
{
type: RELATION_MEMBER_OF,
targetRef: 'group:default/team-a',
target: {
kind: 'Group',
kind: 'group',
namespace: 'default',
name: 'team-a',
},
@@ -100,8 +101,9 @@ describe('CatalogIdentityClient', () => {
relations: [
{
type: RELATION_MEMBER_OF,
targetRef: 'group:reality/screen-actors-guild',
target: {
kind: 'Group',
kind: 'group',
namespace: 'reality',
name: 'screen-actors-guild',
},
@@ -120,11 +120,11 @@ export class CatalogIdentityClient {
e =>
e!.relations
?.filter(r => r.type === RELATION_MEMBER_OF)
.map(r => r.target) ?? [],
.map(r => r.targetRef) ?? [],
);
const newEntityRefs = [
...new Set(resolvedEntityRefs.concat(memberOf).map(stringifyEntityRef)),
...new Set(resolvedEntityRefs.map(stringifyEntityRef).concat(memberOf)),
];
logger?.debug(`Found catalog membership: ${newEntityRefs.join()}`);
@@ -27,11 +27,9 @@ export function getEntityClaims(entity: UserEntity): TokenParams['claims'] {
const membershipRefs =
entity.relations
?.filter(
r =>
r.type === RELATION_MEMBER_OF &&
r.target.kind.toLocaleLowerCase('en-US') === 'group',
r => r.type === RELATION_MEMBER_OF && r.targetRef.startsWith('group:'),
)
.map(r => stringifyEntityRef(r.target)) ?? [];
.map(r => r.targetRef) ?? [];
return {
sub: userRef,
+1 -1
View File
@@ -40,7 +40,7 @@ export type EntityPagination = {
};
/**
* Matches rows in the entities_search table.
* Matches rows in the search table.
* @public
*/
export type EntitiesSearchFilter = {
@@ -29,6 +29,7 @@ describe('isEntityOwner', () => {
relations: [
{
type: 'ownedBy',
targetRef: 'user:default/spiderman',
target: {
kind: 'user',
namespace: 'default',
@@ -52,6 +53,7 @@ describe('isEntityOwner', () => {
relations: [
{
type: 'ownedBy',
targetRef: 'user:default/green-goblin',
target: {
kind: 'user',
namespace: 'default',
@@ -14,16 +14,13 @@
* limitations under the License.
*/
import {
Entity,
RELATION_OWNED_BY,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { createCatalogPermissionRule } from './util';
/**
* A catalog {@link @backstage/plugin-permission-node#PermissionRule} which
* filters for entities with a specified owner.
*
* @public
*/
export const isEntityOwner = createCatalogPermissionRule({
@@ -36,7 +33,7 @@ export const isEntityOwner = createCatalogPermissionRule({
return resource.relations
.filter(relation => relation.type === RELATION_OWNED_BY)
.some(relation => claims.includes(stringifyEntityRef(relation.target)));
.some(relation => claims.includes(relation.targetRef));
},
toQuery: (claims: string[]) => ({
key: 'relations.ownedBy',
@@ -469,6 +469,62 @@ describe('DefaultEntitiesCatalog', () => {
expect(entities.length).toBe(0);
},
);
it.each(databases.eachSupportedId())(
'should return both target and targetRef for entities',
async databaseId => {
const { knex } = await createDatabase(databaseId);
await addEntity(
knex,
{
apiVersion: 'a',
kind: 'k',
metadata: { name: 'one' },
spec: {},
relations: [{ type: 'r', targetRef: 'x:y/z' } as any],
},
[],
);
await addEntity(
knex,
{
apiVersion: 'a',
kind: 'k',
metadata: { name: 'two' },
spec: {},
relations: [
{
type: 'r',
target: { kind: 'x', namespace: 'y', name: 'z' },
} as any,
],
},
[],
);
const catalog = new DefaultEntitiesCatalog(knex);
const { entities } = await catalog.entities();
expect(
entities.find(e => e.metadata.name === 'one')!.relations,
).toEqual([
{
type: 'r',
targetRef: 'x:y/z',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
]);
expect(
entities.find(e => e.metadata.name === 'two')!.relations,
).toEqual([
{
type: 'r',
targetRef: 'x:y/z',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
]);
},
);
});
describe('removeEntityByUid', () => {
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import {
Entity,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { InputError, NotFoundError } from '@backstage/errors';
import { Knex } from 'knex';
import lodash from 'lodash';
@@ -194,11 +198,32 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
};
}
const dbResponse = rows.map(e => JSON.parse(e.final_entity!));
let entities: Entity[] = rows.map(e => JSON.parse(e.final_entity!));
const entities = dbResponse.map(e =>
request?.fields ? request.fields(e) : e,
);
if (request?.fields) {
entities = entities.map(e => request.fields!(e));
}
// TODO(freben): This is added as a compatibility guarantee, until we can be
// sure that all adopters have re-stitched their entities so that the new
// targetRef field is present on them, and that they have stopped consuming
// the now-removed old field
for (const entity of entities) {
if (entity.relations) {
for (const relation of entity.relations) {
if (!relation.targetRef) {
// This is the case where an old-form entity, not yet stitched with
// the updated code, was in the database
relation.targetRef = stringifyEntityRef(relation.target);
} else if (!relation.target) {
// This is the case where a new-form entity, stitched with the
// updated code, was in the database but we still want to produce
// the old data shape as well for compatibility reasons
relation.target = parseEntityRef(relation.targetRef);
}
}
}
}
return {
entities,
@@ -93,6 +93,7 @@ describe('Stitcher', () => {
relations: [
{
type: 'looksAt',
targetRef: 'k:ns/other',
target: {
kind: 'k',
namespace: 'ns',
@@ -159,6 +160,7 @@ describe('Stitcher', () => {
relations: expect.arrayContaining([
{
type: 'looksAt',
targetRef: 'k:ns/other',
target: {
kind: 'k',
namespace: 'ns',
@@ -167,6 +169,7 @@ describe('Stitcher', () => {
},
{
type: 'looksAt',
targetRef: 'k:ns/third',
target: {
kind: 'k',
namespace: 'ns',
@@ -186,6 +186,7 @@ export class Stitcher {
.map(row => ({
type: row.relationType!,
target: parseEntityRef(row.relationTarget!),
targetRef: row.relationTarget!,
}));
if (statusItems.length) {
entity.status = {
@@ -210,7 +211,7 @@ export class Stitcher {
}
// This may throw if the entity is invalid, so we call it before
// the final_entites write, even though we may end up not needing
// the final_entities write, even though we may end up not needing
// to write the search index.
const searchEntries = buildEntitySearch(entityId, entity);
@@ -141,8 +141,16 @@ describe('buildEntitySearch', () => {
it('adds relations', () => {
const input: Entity = {
relations: [
{ type: 't1', target: { kind: 'k', namespace: 'ns', name: 'a' } },
{ type: 't2', target: { kind: 'k', namespace: 'ns', name: 'b' } },
{
type: 't1',
targetRef: 'k:ns/a',
target: { kind: 'k', namespace: 'ns', name: 'a' },
},
{
type: 't2',
targetRef: 'k:ns/b',
target: { kind: 'k', namespace: 'ns', name: 'b' },
},
],
apiVersion: 'a',
kind: 'b',
@@ -182,8 +190,16 @@ describe('buildEntitySearch', () => {
expect(() =>
buildEntitySearch('eid', {
relations: [
{ type: 'dup', target: { kind: 'k', namespace: 'ns', name: 'a' } },
{ type: 'DUP', target: { kind: 'k', namespace: 'ns', name: 'b' } },
{
type: 'dup',
targetRef: 'k:ns/a',
target: { kind: 'k', namespace: 'ns', name: 'a' },
},
{
type: 'DUP',
targetRef: 'k:ns/b',
target: { kind: 'k', namespace: 'ns', name: 'b' },
},
],
apiVersion: 'a',
kind: 'b',
@@ -14,11 +14,7 @@
* limitations under the License.
*/
import {
Entity,
DEFAULT_NAMESPACE,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { InputError } from '@backstage/errors';
import { DbSearchRow } from '../database/tables';
@@ -181,7 +177,7 @@ export function buildEntitySearch(
for (const relation of entity.relations ?? []) {
raw.push({
key: `relations.${relation.type}`,
value: stringifyEntityRef(relation.target),
value: relation.targetRef,
});
}
+5
View File
@@ -118,6 +118,11 @@ const entities = (
},
relations: relations.map(([type, k, n]) => ({
target: { kind: k, name: n, namespace: DEFAULT_NAMESPACE },
targetRef: stringifyEntityRef({
kind: k,
namespace: DEFAULT_NAMESPACE,
name: n,
}),
type,
})),
};
@@ -58,6 +58,7 @@ describe('<CatalogGraphPage/>', () => {
relations: [
{
type: RELATION_PART_OF,
targetRef: 'b:d/e',
target: {
kind: 'b',
namespace: 'd',
@@ -76,6 +77,7 @@ describe('<CatalogGraphPage/>', () => {
relations: [
{
type: RELATION_HAS_PART,
targetRef: 'b:d/c',
target: {
kind: 'b',
namespace: 'd',
@@ -54,6 +54,7 @@ describe('<EntityRelationsGraph/>', () => {
name: 'a1',
namespace: 'd',
},
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
@@ -62,6 +63,7 @@ describe('<EntityRelationsGraph/>', () => {
name: 'c1',
namespace: 'd',
},
targetRef: 'b:d/c1',
type: RELATION_HAS_PART,
},
],
@@ -80,6 +82,7 @@ describe('<EntityRelationsGraph/>', () => {
name: 'c',
namespace: 'd',
},
targetRef: 'b:d/c',
type: RELATION_OWNED_BY,
},
{
@@ -88,6 +91,7 @@ describe('<EntityRelationsGraph/>', () => {
name: 'c1',
namespace: 'd',
},
targetRef: 'b:d/c1',
type: RELATION_OWNED_BY,
},
],
@@ -106,6 +110,7 @@ describe('<EntityRelationsGraph/>', () => {
name: 'c',
namespace: 'd',
},
targetRef: 'b:d/c',
type: RELATION_PART_OF,
},
{
@@ -114,6 +119,7 @@ describe('<EntityRelationsGraph/>', () => {
name: 'a1',
namespace: 'd',
},
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
@@ -122,6 +128,7 @@ describe('<EntityRelationsGraph/>', () => {
name: 'c2',
namespace: 'd',
},
targetRef: 'b:d/c2',
type: RELATION_HAS_PART,
},
],
@@ -140,6 +147,7 @@ describe('<EntityRelationsGraph/>', () => {
name: 'c1',
namespace: 'd',
},
targetRef: 'b:d/c1',
type: RELATION_PART_OF,
},
],
@@ -225,6 +233,7 @@ describe('<EntityRelationsGraph/>', () => {
name: 'some-component',
namespace: 'default',
},
targetRef: 'component:default/some-component',
type: RELATION_OWNER_OF,
},
],
@@ -49,6 +49,7 @@ describe('useEntityRelationGraph', () => {
name: 'a1',
namespace: 'd',
},
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
@@ -57,6 +58,7 @@ describe('useEntityRelationGraph', () => {
name: 'c1',
namespace: 'd',
},
targetRef: 'b:d/c1',
type: RELATION_HAS_PART,
},
],
@@ -75,6 +77,7 @@ describe('useEntityRelationGraph', () => {
name: 'c',
namespace: 'd',
},
targetRef: 'b:d/c',
type: RELATION_OWNED_BY,
},
{
@@ -83,6 +86,7 @@ describe('useEntityRelationGraph', () => {
name: 'c1',
namespace: 'd',
},
targetRef: 'b:d/c1',
type: RELATION_OWNED_BY,
},
],
@@ -101,6 +105,7 @@ describe('useEntityRelationGraph', () => {
name: 'c',
namespace: 'd',
},
targetRef: 'b:d/c',
type: RELATION_PART_OF,
},
{
@@ -109,6 +114,7 @@ describe('useEntityRelationGraph', () => {
name: 'a1',
namespace: 'd',
},
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
@@ -117,6 +123,7 @@ describe('useEntityRelationGraph', () => {
name: 'c2',
namespace: 'd',
},
targetRef: 'b:d/c2',
type: RELATION_HAS_PART,
},
],
@@ -135,6 +142,7 @@ describe('useEntityRelationGraph', () => {
name: 'c1',
namespace: 'd',
},
targetRef: 'b:d/c1',
type: RELATION_PART_OF,
},
],
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { useEffect } from 'react';
import { useEntityStore } from './useEntityStore';
@@ -64,13 +64,15 @@ export function useEntityRelationGraph({
if (
(!relations || relations.includes(rel.type)) &&
(!kinds ||
kinds.includes(rel.target.kind.toLocaleLowerCase('en-US')))
kinds.some(kind =>
rel.targetRef.startsWith(
`${kind.toLocaleLowerCase('en-US')}:`,
),
))
) {
const relationEntityRef = stringifyEntityRef(rel.target);
if (!processedEntityRefs.has(relationEntityRef)) {
nextDepthRefQueue.push(relationEntityRef);
expectedEntities.add(relationEntityRef);
if (!processedEntityRefs.has(rel.targetRef)) {
nextDepthRefQueue.push(rel.targetRef);
expectedEntities.add(rel.targetRef);
}
}
}
@@ -49,6 +49,7 @@ describe('useEntityRelationNodesAndEdges', () => {
name: 'a1',
namespace: 'd',
},
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
@@ -57,6 +58,7 @@ describe('useEntityRelationNodesAndEdges', () => {
name: 'c1',
namespace: 'd',
},
targetRef: 'b:d/c1',
type: RELATION_HAS_PART,
},
],
@@ -75,6 +77,7 @@ describe('useEntityRelationNodesAndEdges', () => {
name: 'c',
namespace: 'd',
},
targetRef: 'b:d/c',
type: RELATION_OWNED_BY,
},
{
@@ -83,6 +86,7 @@ describe('useEntityRelationNodesAndEdges', () => {
name: 'c1',
namespace: 'd',
},
targetRef: 'b:d/c1',
type: RELATION_OWNED_BY,
},
],
@@ -101,6 +105,7 @@ describe('useEntityRelationNodesAndEdges', () => {
name: 'c',
namespace: 'd',
},
targetRef: 'b:d/c',
type: RELATION_PART_OF,
},
{
@@ -109,6 +114,7 @@ describe('useEntityRelationNodesAndEdges', () => {
name: 'a1',
namespace: 'd',
},
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
@@ -117,6 +123,7 @@ describe('useEntityRelationNodesAndEdges', () => {
name: 'c2',
namespace: 'd',
},
targetRef: 'b:d/c2',
type: RELATION_HAS_PART,
},
],
@@ -135,6 +142,7 @@ describe('useEntityRelationNodesAndEdges', () => {
name: 'c1',
namespace: 'd',
},
targetRef: 'b:d/c1',
type: RELATION_PART_OF,
},
],
@@ -13,10 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
DEFAULT_NAMESPACE,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { MouseEvent, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { RelationPairs, ALL_RELATION_PAIRS } from './relations';
@@ -100,11 +97,9 @@ export function useEntityRelationNodesAndEdges({
if (entity) {
entity?.relations?.forEach(rel => {
const targetRef = stringifyEntityRef(rel.target);
// Check if the related entity should be displayed, if not, ignore
// the relation too
if (!entities[targetRef]) {
if (!entities[rel.targetRef]) {
return;
}
@@ -114,12 +109,14 @@ export function useEntityRelationNodesAndEdges({
if (
kinds &&
!kinds.includes(rel.target.kind.toLocaleLowerCase('en-US'))
!kinds.some(kind =>
rel.targetRef.startsWith(`${kind.toLocaleLowerCase('en-US')}:`),
)
) {
return;
}
if (!unidirectional || !visitedNodes.has(targetRef)) {
if (!unidirectional || !visitedNodes.has(rel.targetRef)) {
if (mergeRelations) {
const pair = relationPairs.find(
([l, r]) => l === rel.type || r === rel.type,
@@ -127,24 +124,24 @@ export function useEntityRelationNodesAndEdges({
const [left] = pair;
edges.push({
from: left === rel.type ? entityRef : targetRef,
to: left === rel.type ? targetRef : entityRef,
from: left === rel.type ? entityRef : rel.targetRef,
to: left === rel.type ? rel.targetRef : entityRef,
relations: pair,
label: 'visible',
});
} else {
edges.push({
from: entityRef,
to: targetRef,
to: rel.targetRef,
relations: [rel.type],
label: 'visible',
});
}
}
if (!visitedNodes.has(targetRef)) {
nodeQueue.push(targetRef);
visitedNodes.add(targetRef);
if (!visitedNodes.has(rel.targetRef)) {
nodeQueue.push(rel.targetRef);
visitedNodes.add(rel.targetRef);
}
});
}
+2 -5
View File
@@ -266,7 +266,7 @@ export const EntityRefLink: (props: EntityRefLinkProps) => JSX.Element;
// @public
export type EntityRefLinkProps = {
entityRef: Entity | EntityName;
entityRef: Entity | EntityName | string;
defaultKind?: string;
title?: string;
children?: React_2.ReactNode;
@@ -592,10 +592,7 @@ export function useOwnUser(): AsyncState<UserEntity | undefined>;
// @public (undocumented)
export function useRelatedEntities(
entity: Entity,
{
type,
kind,
}: {
relationFilter: {
type?: string;
kind?: string;
},
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Entity, parseEntityRef } from '@backstage/catalog-model';
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { MockEntityListContextProvider } from '../../testUtils/providers';
@@ -31,6 +31,7 @@ const sampleEntities: Entity[] = [
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/some-owner',
target: {
name: 'some-owner',
namespace: 'default',
@@ -39,6 +40,7 @@ const sampleEntities: Entity[] = [
},
{
type: 'ownedBy',
targetRef: 'group:default/some-owner-2',
target: {
name: 'some-owner-2',
namespace: 'default',
@@ -56,6 +58,7 @@ const sampleEntities: Entity[] = [
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/another-owner',
target: {
name: 'another-owner',
namespace: 'default',
@@ -73,6 +76,7 @@ const sampleEntities: Entity[] = [
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/some-owner',
target: {
name: 'some-owner',
namespace: 'default',
@@ -96,7 +100,7 @@ describe('<EntityOwnerPicker/>', () => {
fireEvent.click(rendered.getByTestId('owner-picker-expand'));
sampleEntities
.flatMap(e => e.relations?.map(r => r.target.name))
.flatMap(e => e.relations?.map(r => parseEntityRef(r.targetRef).name))
.forEach(owner => {
expect(rendered.getByText(owner as string)).toBeInTheDocument();
});
@@ -18,6 +18,7 @@ import {
Entity,
EntityName,
DEFAULT_NAMESPACE,
parseEntityRef,
} from '@backstage/catalog-model';
import React, { forwardRef } from 'react';
import { entityRouteRef } from '../../routes';
@@ -32,7 +33,7 @@ import { Tooltip } from '@material-ui/core';
* @public
*/
export type EntityRefLinkProps = {
entityRef: Entity | EntityName;
entityRef: Entity | EntityName | string;
defaultKind?: string;
title?: string;
children?: React.ReactNode;
@@ -52,7 +53,12 @@ export const EntityRefLink = forwardRef<any, EntityRefLinkProps>(
let namespace;
let name;
if ('metadata' in entityRef) {
if (typeof entityRef === 'string') {
const parsed = parseEntityRef(entityRef);
kind = parsed.kind;
namespace = parsed.namespace;
name = parsed.name;
} else if ('metadata' in entityRef) {
kind = entityRef.kind;
namespace = entityRef.metadata.namespace;
name = entityRef.metadata.name;
@@ -63,15 +69,13 @@ export const EntityRefLink = forwardRef<any, EntityRefLinkProps>(
}
kind = kind.toLocaleLowerCase('en-US');
namespace = namespace?.toLocaleLowerCase('en-US') ?? DEFAULT_NAMESPACE;
const routeParams = {
kind,
namespace: namespace?.toLocaleLowerCase('en-US') ?? DEFAULT_NAMESPACE,
name,
};
const formattedEntityRefTitle = formatEntityRefTitle(entityRef, {
defaultKind,
});
const routeParams = { kind, namespace, name };
const formattedEntityRefTitle = formatEntityRefTitle(
{ kind, namespace, name },
{ defaultKind },
);
const link = (
<Link {...linkProps} ref={ref} to={entityRoute(routeParams)}>
@@ -44,17 +44,19 @@ describe('systemEntityColumns', () => {
relations: [
{
type: RELATION_PART_OF,
targetRef: 'domain:my-namespace/my-domain',
target: {
kind: 'Domain',
kind: 'domain',
name: 'my-domain',
namespace: 'my-namespace',
},
},
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/test',
target: {
kind: 'Group',
name: 'Test',
kind: 'group',
name: 'test',
namespace: 'default',
},
},
@@ -79,7 +81,7 @@ describe('systemEntityColumns', () => {
await waitFor(() => {
expect(getByText('my-namespace/my-system')).toBeInTheDocument();
expect(getByText('my-namespace/my-domain')).toBeInTheDocument();
expect(getByText('Test')).toBeInTheDocument();
expect(getByText('test')).toBeInTheDocument();
expect(getByText(/Some/)).toBeInTheDocument();
});
});
@@ -104,17 +106,19 @@ describe('componentEntityColumns', () => {
relations: [
{
type: RELATION_PART_OF,
targetRef: 'system:my-namespace/my-system',
target: {
kind: 'System',
kind: 'system',
name: 'my-system',
namespace: 'my-namespace',
},
},
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/test',
target: {
kind: 'Group',
name: 'Test',
kind: 'group',
name: 'test',
namespace: 'default',
},
},
@@ -139,7 +143,7 @@ describe('componentEntityColumns', () => {
await waitFor(() => {
expect(getByText('my-namespace/my-component')).toBeInTheDocument();
expect(getByText('my-namespace/my-system')).toBeInTheDocument();
expect(getByText('Test')).toBeInTheDocument();
expect(getByText('test')).toBeInTheDocument();
expect(getByText('production')).toBeInTheDocument();
expect(getByText('service')).toBeInTheDocument();
expect(getByText(/Some/)).toBeInTheDocument();
@@ -15,9 +15,8 @@
*/
import { ComponentEntity, SystemEntity } from '@backstage/catalog-model';
import { columnFactories } from './columns';
import { TableColumn } from '@backstage/core-components';
import { columnFactories } from './columns';
export const systemEntityColumns: TableColumn<SystemEntity>[] = [
columnFactories.createEntityRefColumn({ defaultKind: 'system' }),
@@ -14,23 +14,54 @@
* limitations under the License.
*/
import { parseEntityRef } from '@backstage/catalog-model';
import { useApp } from '@backstage/core-plugin-api';
import WorkIcon from '@material-ui/icons/Work';
import React from 'react';
const DEFAULT_ICON = WorkIcon;
function getKind(
kind: string | undefined,
entityRef: string | undefined,
): string | undefined {
if (kind) {
return kind.toLocaleLowerCase('en-US');
}
if (entityRef) {
try {
return parseEntityRef(entityRef).kind.toLocaleLowerCase('en-US');
} catch {
return undefined;
}
}
return undefined;
}
function useIcon(kind: string | undefined, entityRef: string | undefined) {
const app = useApp();
const actualKind = getKind(kind, entityRef);
if (!actualKind) {
return DEFAULT_ICON;
}
const icon = app.getSystemIcon(`kind:${actualKind}`);
return icon || DEFAULT_ICON;
}
export function EntityKindIcon(props: {
kind: string;
kind?: string;
entityRef?: string;
x?: number;
y?: number;
width?: number;
height?: number;
className?: string;
}) {
const app = useApp();
const { kind, ...otherProps } = props;
const Icon =
app.getSystemIcon(`kind:${kind.toLocaleLowerCase('en-US')}`) ?? WorkIcon;
const { kind, entityRef, ...otherProps } = props;
const Icon = useIcon(kind, entityRef);
return <Icon {...otherProps} />;
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { AlphaEntity, stringifyEntityRef } from '@backstage/catalog-model';
import { AlphaEntity } from '@backstage/catalog-model';
import {
Box,
DialogContentText,
@@ -56,7 +56,7 @@ export function OverviewPage(props: { entity: AlphaEntity }) {
} = props.entity;
const groupedRelations = groupBy(
sortBy(relations, r => stringifyEntityRef(r.target)),
sortBy(relations, r => r.targetRef),
'type',
);
@@ -143,12 +143,14 @@ export function OverviewPage(props: { entity: AlphaEntity }) {
<div key={index}>
<List dense subheader={<ListSubheader>{type}</ListSubheader>}>
{groupRelations.map(group => (
<ListItem key={stringifyEntityRef(group.target)}>
<ListItem key={group.targetRef}>
<ListItemIcon>
<EntityKindIcon kind={group.target.kind} />
<EntityKindIcon entityRef={group.targetRef} />
</ListItemIcon>
<ListItemText
primary={<EntityRefLink entityRef={group.target} />}
primary={
<EntityRefLink entityRef={group.targetRef} />
}
/>
</ListItem>
))}
@@ -103,6 +103,7 @@ const backendEntities: Entity[] = [
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/testuser',
target: { kind: 'User', namespace: 'default', name: 'testUser' },
},
],
@@ -136,6 +137,7 @@ const backendEntities: Entity[] = [
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/testuser',
target: { kind: 'User', namespace: 'default', name: 'testUser' },
},
],
@@ -45,6 +45,7 @@ const entities: Entity[] = [
relations: [
{
type: 'ownedBy',
targetRef: 'user:default/guest',
target: {
name: 'guest',
namespace: 'default',
@@ -70,10 +70,12 @@ describe('useEntityOwnership', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/user1',
target: { kind: 'User', namespace: 'default', name: 'user1' },
},
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/group1',
target: { kind: 'Group', namespace: 'default', name: 'group1' },
},
],
@@ -92,6 +94,7 @@ describe('useEntityOwnership', () => {
relations: [
{
type: RELATION_MEMBER_OF,
targetRef: 'group:default/group1',
target: { kind: 'Group', namespace: 'default', name: 'group1' },
},
],
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, EntityRelation } from '@backstage/catalog-model';
import { Entity, parseEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { chunk, groupBy } from 'lodash';
import useAsync from 'react-use/lib/useAsync';
@@ -24,28 +24,28 @@ const BATCH_SIZE = 20;
/** @public */
export function useRelatedEntities(
entity: Entity,
{ type, kind }: { type?: string; kind?: string },
relationFilter: { type?: string; kind?: string },
): {
entities: Entity[] | undefined;
loading: boolean;
error: Error | undefined;
} {
const filterByTypeLower = relationFilter?.type?.toLocaleLowerCase('en-US');
const filterByKindLower = relationFilter?.kind?.toLocaleLowerCase('en-US');
const catalogApi = useApi(catalogApiRef);
const {
loading,
value: entities,
error,
} = useAsync(async () => {
const relations =
entity.relations &&
entity.relations.filter(
const relations = entity.relations
?.map(r => ({ type: r.type, target: parseEntityRef(r.targetRef) }))
.filter(
r =>
(!type ||
r.type.toLocaleLowerCase('en-US') ===
type.toLocaleLowerCase('en-US')) &&
(!kind ||
r.target.kind.toLocaleLowerCase('en-US') ===
kind.toLocaleLowerCase('en-US')),
(!filterByTypeLower ||
r.type.toLocaleLowerCase('en-US') === filterByTypeLower) &&
(!filterByKindLower || r.target.kind === filterByKindLower),
);
if (!relations) {
@@ -57,7 +57,7 @@ export function useRelatedEntities(
// `filter=kind=component,namespace=default,name=example1&filter=kind=component,namespace=default,name=example2`
// with grouping, we can generate a query a string like
// `filter=kind=component,namespace=default,name=example1,example2`
const relationsByKindAndNamespace: EntityRelation[][] = Object.values(
const relationsByKindAndNamespace = Object.values(
groupBy(relations, ({ target }) => {
return `${target.kind}:${target.namespace}`.toLocaleLowerCase('en-US');
}),
@@ -96,7 +96,7 @@ export function useRelatedEntities(
);
return results.flatMap(r => r.items);
}, [entity, type]);
}, [entity, filterByTypeLower, filterByKindLower]);
return {
entities,
@@ -31,17 +31,19 @@ describe('getEntityRelations', () => {
relations: [
{
type: RELATION_MEMBER_OF,
target: { kind: 'group', name: 'member' },
targetRef: 'group:default/member',
target: { kind: 'group', namespace: 'default', name: 'member' },
},
{
type: RELATION_CHILD_OF,
target: { kind: 'group', name: 'child' },
targetRef: 'group:default/child',
target: { kind: 'group', namespace: 'default', name: 'child' },
},
],
} as Entity;
expect(getEntityRelations(entity, RELATION_MEMBER_OF)).toEqual([
{ kind: 'group', name: 'member' },
{ kind: 'group', namespace: 'default', name: 'member' },
]);
});
@@ -50,17 +52,19 @@ describe('getEntityRelations', () => {
relations: [
{
type: RELATION_MEMBER_OF,
target: { kind: 'group', name: 'member' },
targetRef: 'group:default/member',
target: { kind: 'group', namespace: 'default', name: 'member' },
},
{
type: RELATION_MEMBER_OF,
target: { kind: 'user', name: 'child' },
targetRef: 'user:default/child',
target: { kind: 'user', namespace: 'default', name: 'child' },
},
],
} as Entity;
expect(
getEntityRelations(entity, RELATION_MEMBER_OF, { kind: 'Group' }),
).toEqual([{ kind: 'group', name: 'member' }]);
).toEqual([{ kind: 'group', namespace: 'default', name: 'member' }]);
});
});
@@ -14,10 +14,12 @@
* limitations under the License.
*/
import { Entity, EntityName } from '@backstage/catalog-model';
import { Entity, EntityName, parseEntityRef } from '@backstage/catalog-model';
// TODO(freben): This should be returning entity refs instead
/**
* Get the related entity references.
*
* @public
*/
export function getEntityRelations(
@@ -28,10 +30,10 @@ export function getEntityRelations(
let entityNames =
entity?.relations
?.filter(r => r.type === relationType)
?.map(r => r.target) || [];
.map(r => parseEntityRef(r.targetRef)) || [];
if (filter?.kind) {
entityNames = entityNames?.filter(
entityNames = entityNames.filter(
e =>
e.kind.toLocaleLowerCase('en-US') ===
filter.kind.toLocaleLowerCase('en-US'),
@@ -31,6 +31,7 @@ describe('isOwnerOf', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/user',
target: { kind: 'user', namespace: 'default', name: 'user' },
},
],
@@ -46,6 +47,7 @@ describe('isOwnerOf', () => {
relations: [
{
type: RELATION_MEMBER_OF,
targetRef: 'group:default/group',
target: { kind: 'group', namespace: 'default', name: 'group' },
},
],
@@ -54,6 +56,7 @@ describe('isOwnerOf', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/group',
target: { kind: 'group', namespace: 'default', name: 'group' },
},
],
@@ -63,6 +63,7 @@ describe('<AboutCard />', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/guest',
target: {
kind: 'user',
name: 'guest',
@@ -47,6 +47,7 @@ describe('<AboutContent />', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/o',
target: {
kind: 'user',
name: 'o',
@@ -55,6 +56,7 @@ describe('<AboutContent />', () => {
},
{
type: RELATION_PART_OF,
targetRef: 'system:default/s',
target: {
kind: 'system',
name: 's',
@@ -63,6 +65,7 @@ describe('<AboutContent />', () => {
},
{
type: RELATION_PART_OF,
targetRef: 'domain:default/d',
target: {
kind: 'domain',
name: 'd',
@@ -152,6 +155,7 @@ describe('<AboutContent />', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/guest',
target: {
kind: 'user',
name: 'guest',
@@ -160,6 +164,7 @@ describe('<AboutContent />', () => {
},
{
type: RELATION_PART_OF,
targetRef: 'system:default/system',
target: {
kind: 'system',
name: 'system',
@@ -257,6 +262,7 @@ describe('<AboutContent />', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/guest',
target: {
kind: 'user',
name: 'guest',
@@ -265,6 +271,7 @@ describe('<AboutContent />', () => {
},
{
type: RELATION_PART_OF,
targetRef: 'system:default/system',
target: {
kind: 'system',
name: 'system',
@@ -273,6 +280,7 @@ describe('<AboutContent />', () => {
},
{
type: RELATION_PART_OF,
targetRef: 'component:default/parent-software',
target: {
kind: 'component',
name: 'parent-software',
@@ -369,6 +377,7 @@ describe('<AboutContent />', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/guest',
target: {
kind: 'user',
name: 'guest',
@@ -528,6 +537,7 @@ describe('<AboutContent />', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/guest',
target: {
kind: 'user',
name: 'guest',
@@ -536,6 +546,7 @@ describe('<AboutContent />', () => {
},
{
type: RELATION_PART_OF,
targetRef: 'system:default/system',
target: {
kind: 'system',
name: 'system',
@@ -625,6 +636,7 @@ describe('<AboutContent />', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/guest',
target: {
kind: 'user',
name: 'guest',
@@ -633,6 +645,7 @@ describe('<AboutContent />', () => {
},
{
type: RELATION_PART_OF,
targetRef: 'domain:default/domain',
target: {
kind: 'domain',
name: 'domain',
@@ -73,7 +73,8 @@ describe('DefaultCatalogPage', () => {
relations: [
{
type: RELATION_OWNED_BY,
target: { kind: 'Group', name: 'tools', namespace: 'default' },
targetRef: 'group:default/tools',
target: { kind: 'group', name: 'tools', namespace: 'default' },
},
],
},
@@ -90,8 +91,9 @@ describe('DefaultCatalogPage', () => {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/not-tools',
target: {
kind: 'Group',
kind: 'group',
name: 'not-tools',
namespace: 'default',
},
@@ -110,6 +112,7 @@ describe('DefaultCatalogPage', () => {
relations: [
{
type: RELATION_MEMBER_OF,
targetRef: 'group:default/tools',
target: { namespace: 'default', kind: 'Group', name: 'tools' },
},
],
@@ -81,10 +81,11 @@ describe('<DependencyOfComponentsCard />', () => {
relations: [
{
target: {
kind: 'Component',
kind: 'component',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'component:my-namespace/target-name',
type: RELATION_DEPENDENCY_OF,
},
],
@@ -81,10 +81,11 @@ describe('<DependsOnComponentsCard />', () => {
relations: [
{
target: {
kind: 'Component',
kind: 'component',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'component:my-namespace/target-name',
type: RELATION_DEPENDS_ON,
},
],
@@ -81,10 +81,11 @@ describe('<DependsOnResourcesCard />', () => {
relations: [
{
target: {
kind: 'Resource',
kind: 'resource',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'resource:my-namespace/target-name',
type: RELATION_DEPENDS_ON,
},
],
@@ -81,10 +81,11 @@ describe('<HasComponentsCard />', () => {
relations: [
{
target: {
kind: 'Component',
kind: 'component',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'component:my-namespace/target-name',
type: RELATION_HAS_PART,
},
],
@@ -76,10 +76,11 @@ describe('<HasResourcesCard />', () => {
relations: [
{
target: {
kind: 'Resource',
kind: 'resource',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'resource:my-namespace/target-name',
type: RELATION_HAS_PART,
},
],
@@ -81,10 +81,11 @@ describe('<HasSubcomponentsCard />', () => {
relations: [
{
target: {
kind: 'Component',
kind: 'component',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'component:my-namespace/target-name',
type: RELATION_HAS_PART,
},
],
@@ -79,10 +79,11 @@ describe('<HasSystemsCard />', () => {
relations: [
{
target: {
kind: 'System',
kind: 'system',
namespace: 'my-namespace',
name: 'target-name',
},
targetRef: 'system:my-namespace/target-name',
type: RELATION_HAS_PART,
},
],
@@ -103,10 +103,11 @@ describe('<SystemDiagramCard />', () => {
relations: [
{
target: {
kind: 'Domain',
kind: 'domain',
namespace: 'namespace',
name: 'domain',
},
targetRef: 'domain:namespace/domain',
type: RELATION_PART_OF,
},
],
@@ -166,6 +167,7 @@ describe('<SystemDiagramCard />', () => {
namespace: 'namespace',
name: 'alongdomainthatshouldgettruncated',
},
targetRef: 'domain:namespace/alongdomainthatshouldgettruncated',
type: RELATION_PART_OF,
},
],
+2 -1
View File
@@ -42,7 +42,8 @@ const entity = (name?: string) =>
relations: [
{
type: RELATION_OWNED_BY,
target: { kind: 'Group', namespace: 'default', name },
targetRef: `group:default/${name}`,
target: { kind: 'group', namespace: 'default', name },
},
],
} as Entity);
@@ -28,6 +28,7 @@ export default {
const dummyDepartment = {
type: 'childOf',
targetRef: 'group:default/department-a',
target: {
namespace: 'default',
kind: 'group',
@@ -54,6 +54,7 @@ const makeUser = ({
relations: [
{
type: 'memberOf',
targetRef: 'group:default/team-a',
target: {
namespace: 'default',
kind: 'group',
@@ -59,6 +59,7 @@ describe('MemberTab Test', () => {
relations: [
{
type: 'memberOf',
targetRef: 'group:default/team-d',
target: {
kind: 'group',
name: 'team-d',
@@ -69,9 +69,10 @@ const makeComponent = ({ type, name }: { type: string; name: string }) => ({
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/team-a',
target: {
namespace: 'default',
kind: 'Group',
kind: 'group',
name: 'team-a',
},
},
@@ -43,10 +43,11 @@ const items = [
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/my-team',
target: {
name: 'my-team',
namespace: 'default',
kind: 'Group',
kind: 'group',
},
},
],
@@ -62,10 +63,11 @@ const items = [
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/my-team',
target: {
name: 'my-team',
namespace: 'default',
kind: 'Group',
kind: 'group',
},
},
],
@@ -82,10 +84,11 @@ const items = [
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/my-team',
target: {
name: 'my-team',
namespace: 'default',
kind: 'Group',
kind: 'group',
},
},
],
@@ -99,10 +102,11 @@ const items = [
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/my-team',
target: {
name: 'my-team',
namespace: 'default',
kind: 'Group',
kind: 'group',
},
},
],
@@ -135,9 +139,10 @@ describe('OwnershipCard', () => {
relations: [
{
type: 'memberOf',
targetRef: 'group:default/examplegroup',
target: {
kind: 'group',
name: 'ExampleGroup',
name: 'examplegroup',
namespace: 'default',
},
},
@@ -260,8 +265,9 @@ describe('OwnershipCard', () => {
relations: [
{
type: 'memberOf',
targetRef: 'group:default/my-team',
target: {
kind: 'Group',
kind: 'group',
name: 'my-team',
namespace: 'default',
},
@@ -28,6 +28,7 @@ export default {
const dummyGroup = {
type: 'memberOf',
targetRef: 'group:default/team-a',
target: {
namespace: 'default',
kind: 'group',
@@ -42,6 +42,7 @@ describe('UserSummary Test', () => {
relations: [
{
type: 'memberOf',
targetRef: 'group:default/examplegroup',
target: {
kind: 'group',
name: 'ExampleGroup',
@@ -70,9 +71,9 @@ describe('UserSummary Test', () => {
'src',
'https://example.com/staff/calum.jpeg',
);
expect(rendered.getByText('ExampleGroup')).toHaveAttribute(
expect(rendered.getByText('examplegroup')).toHaveAttribute(
'href',
'/catalog/default/group/ExampleGroup',
'/catalog/default/group/examplegroup',
);
expect(rendered.getByText('Super awesome human')).toBeInTheDocument();
});
@@ -57,6 +57,7 @@ const defaultEntityListResponse: GetEntitiesResponse = {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/my-team',
target: { name: 'team-a', kind: 'group', namespace: 'default' },
},
],
@@ -57,6 +57,7 @@ const defaultEntityListResponse: GetEntitiesResponse = {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/team-a',
target: { name: 'team-a', kind: 'group', namespace: 'default' },
},
],
@@ -57,6 +57,7 @@ const defaultEntityListResponse: GetEntitiesResponse = {
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/team-a',
target: { name: 'team-a', kind: 'group', namespace: 'default' },
},
],
@@ -20,6 +20,7 @@ import {
} from '@backstage/backend-common';
import {
Entity,
parseEntityRef,
RELATION_OWNED_BY,
stringifyEntityRef,
} from '@backstage/catalog-model';
@@ -154,9 +155,7 @@ export class DefaultTechDocsCollator implements DocumentCollator {
entityTitle: entity.metadata.title,
componentType: entity.spec?.type?.toString() || 'other',
lifecycle: (entity.spec?.lifecycle as string) || '',
owner:
entity.relations?.find(r => r.type === RELATION_OWNED_BY)
?.target?.name || '',
owner: getSimpleEntityOwnerString(entity),
authorization: {
resourceRef: stringifyEntityRef(entity),
},
@@ -202,3 +201,14 @@ export class DefaultTechDocsCollator implements DocumentCollator {
}, {} as EntityInfo);
}
}
function getSimpleEntityOwnerString(entity: Entity): string {
if (entity.relations) {
const owner = entity.relations.find(r => r.type === RELATION_OWNED_BY);
if (owner) {
const { name } = parseEntityRef(owner.targetRef);
return name;
}
}
return '';
}
@@ -66,6 +66,7 @@ describe('DocsTable test', () => {
namespace: 'default',
name: 'owned',
},
targetRef: 'user:default/owned',
type: 'ownedBy',
},
],
@@ -86,6 +87,7 @@ describe('DocsTable test', () => {
namespace: 'default',
name: 'not-owned',
},
targetRef: 'user:default/not-owned',
type: 'ownedBy',
},
],
@@ -137,6 +139,7 @@ describe('DocsTable test', () => {
namespace: 'default',
name: 'owned',
},
targetRef: 'user:default/owned',
type: 'ownedBy',
},
],