diff --git a/.changeset/mighty-suns-drop.md b/.changeset/mighty-suns-drop.md
new file mode 100644
index 0000000000..f8bb686aaf
--- /dev/null
+++ b/.changeset/mighty-suns-drop.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-org': patch
+---
+
+add aggregated ownership type for kind group in OwnershipCard
diff --git a/plugins/org/package.json b/plugins/org/package.json
index 11478bbe64..8b8c6709dc 100644
--- a/plugins/org/package.json
+++ b/plugins/org/package.json
@@ -34,6 +34,7 @@
"@material-ui/lab": "4.0.0-alpha.57",
"pluralize": "^8.0.0",
"qs": "^6.10.1",
+ "p-limit": "^3.1.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
diff --git a/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx b/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx
new file mode 100644
index 0000000000..6f7680849c
--- /dev/null
+++ b/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2020 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Entity } from '@backstage/catalog-model';
+import { Link, Progress, ResponseErrorPanel } from '@backstage/core-components';
+import { useRouteRef } from '@backstage/core-plugin-api';
+import { BackstageTheme } from '@backstage/theme';
+import {
+ Box,
+ createStyles,
+ Grid,
+ makeStyles,
+ Typography,
+} from '@material-ui/core';
+import React from 'react';
+import pluralize from 'pluralize';
+import { catalogIndexRouteRef } from '../../../routes';
+import { useGetEntities } from './useGetEntities';
+
+const useStyles = makeStyles((theme: BackstageTheme) =>
+ createStyles({
+ card: {
+ border: `1px solid ${theme.palette.divider}`,
+ boxShadow: theme.shadows[2],
+ borderRadius: '4px',
+ padding: theme.spacing(2),
+ color: '#fff',
+ transition: `${theme.transitions.duration.standard}ms`,
+ '&:hover': {
+ boxShadow: theme.shadows[4],
+ },
+ },
+ bold: {
+ fontWeight: theme.typography.fontWeightBold,
+ },
+ entityTypeBox: {
+ background: (props: { type: string }) =>
+ theme.getPageTheme({ themeId: props.type }).backgroundImage,
+ },
+ }),
+);
+
+const EntityCountTile = ({
+ counter,
+ type,
+ name,
+ url,
+}: {
+ counter: number;
+ type: string;
+ name: string;
+ url: string;
+}) => {
+ const classes = useStyles({ type });
+
+ return (
+
+
+
+ {counter}
+
+
+ {pluralize(name, counter)}
+
+
+
+ );
+};
+
+export const ComponentsGrid = ({
+ entity,
+ relationsType,
+ isGroup,
+ entityFilterKind,
+}: {
+ entity: Entity;
+ relationsType: string;
+ isGroup: boolean;
+ entityFilterKind?: string[];
+}) => {
+ const catalogLink = useRouteRef(catalogIndexRouteRef);
+ const { componentsWithCounters, loading, error } = useGetEntities(
+ entity,
+ relationsType,
+ isGroup,
+ entityFilterKind,
+ );
+
+ if (loading) {
+ return ;
+ } else if (error) {
+ return ;
+ }
+
+ return (
+
+ {componentsWithCounters?.map(c => (
+
+
+
+ ))}
+
+ );
+};
diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx
index 483cf9744f..dcaae2d4bf 100644
--- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx
+++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx
@@ -112,9 +112,9 @@ const getEntitiesMock = (
request?: GetEntitiesRequest,
): Promise => {
const filterKinds =
- !Array.isArray(request?.filter) && Array.isArray(request?.filter?.kind)
- ? request?.filter?.kind ?? []
- : []; // we expect the request to be like { filter: { kind: ['API','System'], .... }. If changed in OwnerShipCard, let's change in also here
+ Array.isArray(request?.filter) && Array.isArray(request?.filter[0].kind)
+ ? request?.filter[0].kind ?? []
+ : []; // we expect the request to be like { filter: [{ kind: ['API','System'], 'relations.ownedBy': [group:default/my-team], .... }]. If changed in OwnerShipCard, let's change in also here
return Promise.resolve({
items: items.filter(item => filterKinds.find(k => k === item.kind)),
} as GetEntitiesResponse);
@@ -160,7 +160,12 @@ describe('OwnershipCard', () => {
);
expect(catalogApi.getEntities).toHaveBeenCalledWith({
- filter: { kind: ['Component', 'API'] },
+ filter: [
+ {
+ kind: ['Component', 'API', 'System'],
+ 'relations.ownedBy': ['group:default/my-team'],
+ },
+ ],
fields: [
'kind',
'metadata.name',
@@ -182,7 +187,10 @@ describe('OwnershipCard', () => {
expect(
queryByText(getByText('LIBRARY').parentElement!, '1'),
).toBeInTheDocument();
- expect(() => getByText('SYSTEM')).toThrowError();
+ expect(getByText('SYSTEM')).toBeInTheDocument();
+ expect(
+ queryByText(getByText('SYSTEM').parentElement!, '1'),
+ ).toBeInTheDocument();
});
it('applies CustomFilterDefinition', async () => {
@@ -238,7 +246,7 @@ describe('OwnershipCard', () => {
expect(getByText('OPENAPI').closest('a')).toHaveAttribute(
'href',
- '/create/?filters%5Bkind%5D=API&filters%5Btype%5D=openapi&filters%5Bowners%5D=my-team&filters%5Buser%5D=all',
+ '/create/?filters%5Bkind%5D=API&filters%5Btype%5D=openapi&filters%5Bowners%5D%5B0%5D=my-team&filters%5Buser%5D=all',
);
});
@@ -280,7 +288,7 @@ describe('OwnershipCard', () => {
expect(getByText('OPENAPI').closest('a')).toHaveAttribute(
'href',
- '/create/?filters%5Bkind%5D=API&filters%5Btype%5D=openapi&filters%5Bowners%5D=user%3Athe-user&filters%5Bowners%5D=my-team&filters%5Buser%5D=all',
+ '/create/?filters%5Bkind%5D=API&filters%5Btype%5D=openapi&filters%5Bowners%5D%5B0%5D=the-user&filters%5Bowners%5D%5B1%5D=my-team&filters%5Buser%5D=all',
);
});
});
diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx
index fb49843e92..13e8502b7a 100644
--- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx
+++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx
@@ -14,123 +14,42 @@
* limitations under the License.
*/
-import { Entity, UserEntity } from '@backstage/catalog-model';
+import { InfoCard, InfoCardVariants } from '@backstage/core-components';
+import { useEntity } from '@backstage/plugin-catalog-react';
import {
- InfoCard,
- InfoCardVariants,
- Link,
- Progress,
- ResponseErrorPanel,
-} from '@backstage/core-components';
-import { useApi, useRouteRef } from '@backstage/core-plugin-api';
-import {
- catalogApiRef,
- humanizeEntityRef,
- isOwnerOf,
- useEntity,
-} from '@backstage/plugin-catalog-react';
-import { BackstageTheme } from '@backstage/theme';
-import {
- Box,
- createStyles,
- Grid,
+ List,
+ ListItem,
+ ListItemSecondaryAction,
+ ListItemText,
makeStyles,
- Typography,
+ Switch,
+ Tooltip,
} from '@material-ui/core';
-import qs from 'qs';
-import React from 'react';
-import pluralize from 'pluralize';
-import useAsync from 'react-use/lib/useAsync';
-import { catalogIndexRouteRef } from '../../../routes';
+import React, { useState } from 'react';
+import { ComponentsGrid } from './ComponentsGrid';
-type EntityTypeProps = {
- kind: string;
- type: string;
- count: number;
-};
-
-const useStyles = makeStyles((theme: BackstageTheme) =>
- createStyles({
- card: {
- border: `1px solid ${theme.palette.divider}`,
- boxShadow: theme.shadows[2],
- borderRadius: '4px',
- padding: theme.spacing(2),
- color: '#fff',
- transition: `${theme.transitions.duration.standard}ms`,
- '&:hover': {
- boxShadow: theme.shadows[4],
- },
+const useStyles = makeStyles(theme => ({
+ list: {
+ [theme.breakpoints.down('xs')]: {
+ padding: `0 0 12px`,
},
- bold: {
- fontWeight: theme.typography.fontWeightBold,
+ },
+ listItemText: {
+ [theme.breakpoints.down('xs')]: {
+ paddingRight: 0,
+ paddingLeft: 0,
},
- entityTypeBox: {
- background: (props: { type: string }) =>
- theme.getPageTheme({ themeId: props.type }).backgroundImage,
+ },
+ listItemSecondaryAction: {
+ [theme.breakpoints.down('xs')]: {
+ width: '100%',
+ top: 'auto',
+ right: 'auto',
+ position: 'relative',
+ transform: 'unset',
},
- }),
-);
-
-const EntityCountTile = ({
- counter,
- type,
- name,
- url,
-}: {
- counter: number;
- type: string;
- name: string;
- url: string;
-}) => {
- const classes = useStyles({ type });
-
- return (
-
-
-
- {counter}
-
-
- {pluralize(name, counter)}
-
-
-
- );
-};
-
-const getQueryParams = (
- owner: Entity,
- selectedEntity: EntityTypeProps,
-): string => {
- const ownerName = humanizeEntityRef(owner, { defaultKind: 'group' });
- const { kind, type } = selectedEntity;
- const filters = {
- kind,
- type,
- owners: [ownerName],
- user: 'all',
- };
- if (owner.kind === 'User') {
- const user = owner as UserEntity;
- filters.owners = [...filters.owners, ...(user.spec.memberOf ?? [])];
- }
- const queryParams = qs.stringify(
- {
- filters,
- },
- {
- arrayFormat: 'repeat',
- },
- );
-
- return queryParams;
-};
+ },
+}));
export const OwnershipCard = ({
variant,
@@ -139,90 +58,48 @@ export const OwnershipCard = ({
variant?: InfoCardVariants;
entityFilterKind?: string[];
}) => {
+ const classes = useStyles();
const { entity } = useEntity();
- const catalogApi = useApi(catalogApiRef);
- const catalogLink = useRouteRef(catalogIndexRouteRef);
-
- const {
- loading,
- error,
- value: componentsWithCounters,
- } = useAsync(async () => {
- const kinds = entityFilterKind ?? ['Component', 'API'];
- const entitiesList = await catalogApi.getEntities({
- filter: {
- kind: kinds,
- },
- fields: [
- 'kind',
- 'metadata.name',
- 'metadata.namespace',
- 'spec.type',
- 'relations',
- ],
- });
-
- const ownedEntitiesList = entitiesList.items.filter(component =>
- isOwnerOf(entity, component),
- );
-
- const counts = ownedEntitiesList.reduce(
- (acc: EntityTypeProps[], ownedEntity) => {
- const match = acc.find(
- x =>
- x.kind === ownedEntity.kind &&
- x.type === (ownedEntity.spec?.type ?? ownedEntity.kind),
- );
- if (match) {
- match.count += 1;
- } else {
- acc.push({
- kind: ownedEntity.kind,
- type: ownedEntity.spec?.type?.toString() ?? ownedEntity.kind,
- count: 1,
- });
- }
- return acc;
- },
- [],
- );
-
- // Return top N (six) entities to be displayed in ownership boxes
- const topN = counts.sort((a, b) => b.count - a.count).slice(0, 6);
-
- return topN.map(topOwnedEntity => ({
- counter: topOwnedEntity.count,
- type: topOwnedEntity.type,
- name: topOwnedEntity.type.toLocaleUpperCase('en-US'),
- queryParams: getQueryParams(entity, topOwnedEntity),
- })) as Array<{
- counter: number;
- type: string;
- name: string;
- queryParams: string;
- }>;
- }, [catalogApi, entity]);
-
- if (loading) {
- return ;
- } else if (error) {
- return ;
- }
+ const isGroup = entity.kind === 'Group';
+ const [relationsType, setRelationsType] = useState('direct');
return (
-
- {componentsWithCounters?.map(c => (
-
-
-
- ))}
-
+
+
+
+
+ Direct Relations
+
+
+ relationsType === 'direct'
+ ? setRelationsType('aggregated')
+ : setRelationsType('direct')
+ }
+ name="pin"
+ inputProps={{ 'aria-label': 'Ownership Type Switch' }}
+ disabled={!isGroup}
+ />
+
+ Aggregated Relations
+
+
+
+
);
};
diff --git a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts
new file mode 100644
index 0000000000..48760002fa
--- /dev/null
+++ b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2020 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ Entity,
+ RELATION_MEMBER_OF,
+ RELATION_PARENT_OF,
+ stringifyEntityRef,
+} from '@backstage/catalog-model';
+import {
+ CatalogApi,
+ catalogApiRef,
+ getEntityRelations,
+} from '@backstage/plugin-catalog-react';
+import limiterFactory from 'p-limit';
+import { useApi } from '@backstage/core-plugin-api';
+import useAsync from 'react-use/lib/useAsync';
+import qs from 'qs';
+
+const limiter = limiterFactory(10);
+
+type EntityTypeProps = {
+ kind: string;
+ type: string;
+ count: number;
+};
+
+const getQueryParams = (
+ ownersEntityRef: string[],
+ selectedEntity: EntityTypeProps,
+): string => {
+ const { kind, type } = selectedEntity;
+ const owners = ownersEntityRef.map(owner => owner.split('/')[1]);
+ const filters = {
+ kind,
+ type,
+ owners,
+ user: 'all',
+ };
+ const queryParams = qs.stringify({
+ filters,
+ });
+
+ return queryParams;
+};
+
+const getOwnersEntityRef = (owner: Entity): string[] => {
+ let owners = [stringifyEntityRef(owner)];
+ if (owner.kind === 'User') {
+ const ownerGroups = getEntityRelations(owner, RELATION_MEMBER_OF, {
+ kind: 'Group',
+ });
+ const ownerGroupsName = ownerGroups.map(ownerGroup =>
+ stringifyEntityRef({
+ kind: ownerGroup.kind,
+ namespace: ownerGroup.namespace,
+ name: ownerGroup.name,
+ }),
+ );
+ owners = [...owners, ...ownerGroupsName];
+ }
+ return owners;
+};
+
+const getAggregatedOwnersEntityRef = async (
+ parentGroup: Entity,
+ catalogApi: CatalogApi,
+): Promise => {
+ const requestedEntities: Entity[] = [];
+ const outstandingEntities = new Map>();
+ const processedEntities = new Set();
+ requestedEntities.push(parentGroup);
+ let currentEntity = parentGroup;
+
+ while (requestedEntities.length > 0) {
+ const childRelations = getEntityRelations(
+ currentEntity,
+ RELATION_PARENT_OF,
+ {
+ kind: 'Group',
+ },
+ );
+
+ await Promise.all(
+ childRelations.map(childGroup =>
+ limiter(async () => {
+ const promise = catalogApi.getEntityByRef(childGroup);
+ outstandingEntities.set(childGroup.name, promise);
+ try {
+ const processedEntity = await promise;
+ if (processedEntity) {
+ requestedEntities.push(processedEntity);
+ }
+ } finally {
+ outstandingEntities.delete(childGroup.name);
+ }
+ }),
+ ),
+ );
+ requestedEntities.shift();
+ processedEntities.add(
+ stringifyEntityRef({
+ kind: currentEntity.kind,
+ namespace: currentEntity.metadata.namespace,
+ name: currentEntity.metadata.name,
+ }),
+ );
+ // always set currentEntity to the first element of array requestedEntities
+ currentEntity = requestedEntities[0];
+ }
+
+ return Array.from(processedEntities);
+};
+
+export function useGetEntities(
+ entity: Entity,
+ relationsType: string,
+ isGroup: boolean,
+ entityFilterKind?: string[],
+): {
+ componentsWithCounters:
+ | {
+ counter: number;
+ type: string;
+ name: string;
+ queryParams: string;
+ }[]
+ | undefined;
+ loading: boolean;
+ error?: Error;
+} {
+ const catalogApi = useApi(catalogApiRef);
+ const kinds = entityFilterKind ?? ['Component', 'API', 'System'];
+
+ const {
+ loading,
+ error,
+ value: componentsWithCounters,
+ } = useAsync(async () => {
+ const owners =
+ relationsType === 'aggregated' && isGroup
+ ? await getAggregatedOwnersEntityRef(entity, catalogApi)
+ : getOwnersEntityRef(entity);
+ const ownedEntitiesList = await catalogApi.getEntities({
+ filter: [
+ {
+ kind: kinds,
+ 'relations.ownedBy': owners,
+ },
+ ],
+ fields: [
+ 'kind',
+ 'metadata.name',
+ 'metadata.namespace',
+ 'spec.type',
+ 'relations',
+ ],
+ });
+
+ const counts = ownedEntitiesList.items.reduce(
+ (acc: EntityTypeProps[], ownedEntity) => {
+ const match = acc.find(
+ x =>
+ x.kind === ownedEntity.kind &&
+ x.type === (ownedEntity.spec?.type ?? ownedEntity.kind),
+ );
+ if (match) {
+ match.count += 1;
+ } else {
+ acc.push({
+ kind: ownedEntity.kind,
+ type: ownedEntity.spec?.type?.toString() ?? ownedEntity.kind,
+ count: 1,
+ });
+ }
+ return acc;
+ },
+ [],
+ );
+
+ // Return top N (six) entities to be displayed in ownership boxes
+ const topN = counts.sort((a, b) => b.count - a.count).slice(0, 6);
+
+ return topN.map(topOwnedEntity => ({
+ counter: topOwnedEntity.count,
+ type: topOwnedEntity.type,
+ name: topOwnedEntity.type.toLocaleUpperCase('en-US'),
+ queryParams: getQueryParams(owners, topOwnedEntity),
+ })) as Array<{
+ counter: number;
+ type: string;
+ name: string;
+ queryParams: string;
+ }>;
+ }, [catalogApi, entity, relationsType]);
+
+ return {
+ componentsWithCounters,
+ loading,
+ error,
+ };
+}