diff --git a/.changeset/unlucky-planets-burn.md b/.changeset/unlucky-planets-burn.md new file mode 100644 index 0000000..15bea9e --- /dev/null +++ b/.changeset/unlucky-planets-burn.md @@ -0,0 +1,7 @@ +--- +'@dweber019/backstage-plugin-tips': patch +--- + +Add identify api for tip activation and use ownership for missing data. + +**breaking**: This changes the interface of a tip to become a promise. diff --git a/plugins/tips/src/components/EntityTipsDialog/EntityTipsDialog.test.tsx b/plugins/tips/src/components/EntityTipsDialog/EntityTipsDialog.test.tsx index 84a8144..5a0a883 100644 --- a/plugins/tips/src/components/EntityTipsDialog/EntityTipsDialog.test.tsx +++ b/plugins/tips/src/components/EntityTipsDialog/EntityTipsDialog.test.tsx @@ -1,19 +1,3 @@ -/* - * Copyright 2021 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 { ComponentEntity, EntityLink, @@ -25,12 +9,24 @@ import React from 'react'; import { TipsConfig, tipsConfigRef } from '../../config'; import { EntityTipsDialog } from './EntityTipsDialog'; import { extraTips } from '../../lib/extraTips'; +import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api'; describe('EntityTipsDialog', () => { const mockTipsConfig: jest.Mocked = { tips: extraTips, }; + const mockIdentityApi: jest.Mocked = { + getBackstageIdentity: jest.fn().mockResolvedValue({ + type: 'user', + userEntityRef: 'user:default/owner', + ownershipEntityRefs: ['user:default/owner'], + }), + signOut: jest.fn(), + getCredentials: jest.fn(), + getProfileInfo: jest.fn(), + }; + const links: EntityLink[] = [{ url: 'link' }]; const documentationAnnotation = { 'backstage.io/techdocs-ref': 'any' }; @@ -39,11 +35,11 @@ describe('EntityTipsDialog', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock', links }, kind: 'Component', - spec: { system: 'any' }, + spec: { system: 'any', owner: 'user:default/owner' }, } as ComponentEntity; const rendered = await renderWithEffects( - + @@ -64,11 +60,11 @@ describe('EntityTipsDialog', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock', annotations: documentationAnnotation, links }, kind: 'System', - spec: { owner: 'any' }, + spec: { owner: 'user:default/owner' }, } as SystemEntity; const rendered = await renderWithEffects( - + @@ -89,11 +85,11 @@ describe('EntityTipsDialog', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock', links, annotations: documentationAnnotation }, kind: 'Component', - spec: { system: 'any', type: 'any', owner: 'any', lifecycle: 'any' }, + spec: { system: 'any', type: 'any', owner: 'user:default/owner', lifecycle: 'any' }, } as ComponentEntity; const rendered = await renderWithEffects( - + diff --git a/plugins/tips/src/components/EntityTipsDialog/EntityTipsDialog.tsx b/plugins/tips/src/components/EntityTipsDialog/EntityTipsDialog.tsx index 9466b11..4316787 100644 --- a/plugins/tips/src/components/EntityTipsDialog/EntityTipsDialog.tsx +++ b/plugins/tips/src/components/EntityTipsDialog/EntityTipsDialog.tsx @@ -1,20 +1,4 @@ -/* - * Copyright 2022 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, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { ApiEntity } from '@backstage/catalog-model'; import EmojiObjectsIcon from '@material-ui/icons/EmojiObjects'; @@ -33,8 +17,9 @@ import { Zoom, } from '@material-ui/core'; import { MarkdownContent } from '@backstage/core-components'; -import { useApi } from '@backstage/core-plugin-api'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; import { tipsConfigRef } from '../../config'; +import useAsync from 'react-use/lib/useAsync'; const useStyles = makeStyles(theme => ({ fabButton: { @@ -86,6 +71,7 @@ export const EntityTipsDialog = () => { const classes = useStyles(); const { entity } = useEntity(); const tipsConfig = useApi(tipsConfigRef); + const identity = useApi(identityApiRef); const [isDialogOpen, setIsDialogOpen] = useState(false); const toggleDialog = () => setIsDialogOpen(!isDialogOpen); @@ -97,12 +83,13 @@ export const EntityTipsDialog = () => { setActiveStep(prevActiveStep => prevActiveStep - 1); }; - const tips = useMemo(() => { + const { value: tips, loading, error } = useAsync(async () => { setActiveStep(0); - return tipsConfig.tips.filter(tip => tip.activate({ entity })); - }, [tipsConfig.tips, entity]); + const resolvedActivates = await Promise.all(tipsConfig.tips.map(tip => tip.activate({ entity, identity }))); + return tipsConfig.tips.filter((_, index) => resolvedActivates[index]); + }, [tipsConfig.tips, entity, identity]); - if (tips.length === 0 || !tips[activeStep]) { + if (loading || error || tips === undefined || tips.length === 0 || !tips[activeStep]) { return <>; } diff --git a/plugins/tips/src/config.ts b/plugins/tips/src/config.ts index 2ce370d..012ecc8 100644 --- a/plugins/tips/src/config.ts +++ b/plugins/tips/src/config.ts @@ -1,4 +1,4 @@ -import { createApiRef } from '@backstage/core-plugin-api'; +import { createApiRef, IdentityApi } from '@backstage/core-plugin-api'; import React from 'react'; import { Entity } from '@backstage/catalog-model'; @@ -15,5 +15,5 @@ export interface TipsConfig { export interface Tip { content: string | React.ReactElement; title: string; - activate: (options: { entity?: Entity }) => boolean; + activate: (options: { entity?: Entity, identity?: IdentityApi }) => Promise; } diff --git a/plugins/tips/src/index.ts b/plugins/tips/src/index.ts index e0637c1..a91a9dc 100644 --- a/plugins/tips/src/index.ts +++ b/plugins/tips/src/index.ts @@ -1,22 +1,6 @@ -/* - * Copyright 2022 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. - */ - /** @packageDocumentation */ -export { hasAnnotation, isEntityOfKind } from './lib/utils'; +export * from './lib/utils'; export { extraTips } from './lib/extraTips'; export { systemModelTips } from './lib/systemModelTips'; export { tipsPlugin, EntityTipsDialog } from './plugin'; diff --git a/plugins/tips/src/lib/extraTips.test.tsx b/plugins/tips/src/lib/extraTips.test.tsx index 68fc888..9a63885 100644 --- a/plugins/tips/src/lib/extraTips.test.tsx +++ b/plugins/tips/src/lib/extraTips.test.tsx @@ -1,27 +1,23 @@ -/* - * Copyright 2021 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, EntityLink } from '@backstage/catalog-model'; import { extraTips } from './extraTips'; import { Tip } from '../config'; +import { IdentityApi } from '@backstage/core-plugin-api'; describe('Defaults tips', () => { const links: EntityLink[] = [{ url: 'link' }]; const documentationAnnotation = { 'backstage.io/techdocs-ref': 'any' }; + const mockIdentityApi: jest.Mocked = { + getBackstageIdentity: jest.fn().mockResolvedValue({ + type: 'user', + userEntityRef: 'user:default/owner', + ownershipEntityRefs: ['user:default/owner'], + }), + signOut: jest.fn(), + getCredentials: jest.fn(), + getProfileInfo: jest.fn(), + }; + const getTipActivateByTitle = (title: string) => extraTips.find(tip => tip.title === title) as Tip; @@ -30,24 +26,28 @@ describe('Defaults tips', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock' }, kind: 'Component', + spec: { owner: 'user:default/owner' } } as Entity; - const activate = getTipActivateByTitle('Documentation missing').activate({ + const activate = await getTipActivateByTitle('Documentation missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeTruthy(); }); - it('should not activate on documentation exiting', async () => { + it('should not activate on documentation existing', async () => { const mockEntity = { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock', annotations: documentationAnnotation }, kind: 'Component', + spec: { owner: 'user:default/owner' } } as Entity; - const activate = getTipActivateByTitle('Documentation missing').activate({ + const activate = await getTipActivateByTitle('Documentation missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeFalsy(); @@ -58,10 +58,12 @@ describe('Defaults tips', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock' }, kind: 'Component', + spec: { owner: 'user:default/owner' } } as Entity; - const activate = getTipActivateByTitle('Links missing').activate({ + const activate = await getTipActivateByTitle('Links missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeTruthy(); @@ -72,10 +74,12 @@ describe('Defaults tips', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock', links: [] }, kind: 'Component', + spec: { owner: 'user:default/owner' } } as Entity; - const activate = getTipActivateByTitle('Links missing').activate({ + const activate = await getTipActivateByTitle('Links missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeTruthy(); @@ -86,10 +90,12 @@ describe('Defaults tips', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock', links }, kind: 'Component', + spec: { owner: 'user:default/owner' } } as Entity; - const activate = getTipActivateByTitle('Links missing').activate({ + const activate = await getTipActivateByTitle('Links missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeFalsy(); @@ -100,11 +106,12 @@ describe('Defaults tips', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock' }, kind: 'Component', - spec: {}, + spec: { owner: 'user:default/owner' } } as Entity; - const activate = getTipActivateByTitle('System missing').activate({ + const activate = await getTipActivateByTitle('System missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeTruthy(); @@ -115,11 +122,12 @@ describe('Defaults tips', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock' }, kind: 'Component', - spec: { system: 'any' }, + spec: { system: 'any', owner: 'user:default/owner' }, } as Entity; - const activate = getTipActivateByTitle('System missing').activate({ + const activate = await getTipActivateByTitle('System missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeFalsy(); @@ -130,11 +138,12 @@ describe('Defaults tips', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock' }, kind: 'Group', - spec: {}, + spec: {} } as Entity; - const activate = getTipActivateByTitle('Members missing').activate({ + const activate = await getTipActivateByTitle('Members missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeTruthy(); @@ -148,8 +157,9 @@ describe('Defaults tips', () => { spec: { members: [] }, } as Entity; - const activate = getTipActivateByTitle('Members missing').activate({ + const activate = await getTipActivateByTitle('Members missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeTruthy(); @@ -163,8 +173,9 @@ describe('Defaults tips', () => { spec: { members: ['user1'] }, } as Entity; - const activate = getTipActivateByTitle('Members missing').activate({ + const activate = await getTipActivateByTitle('Members missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeFalsy(); @@ -175,11 +186,12 @@ describe('Defaults tips', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock' }, kind: 'System', - spec: {}, + spec: { owner: 'user:default/owner' }, } as Entity; - const activate = getTipActivateByTitle('Domain missing').activate({ + const activate = await getTipActivateByTitle('Domain missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeTruthy(); @@ -190,11 +202,12 @@ describe('Defaults tips', () => { apiVersion: 'backstage.io/v1beta1', metadata: { name: 'mock' }, kind: 'System', - spec: { domain: 'any' }, + spec: { domain: 'any', owner: 'user:default/owner' }, } as Entity; - const activate = getTipActivateByTitle('Domain missing').activate({ + const activate = await getTipActivateByTitle('Domain missing').activate({ entity: mockEntity, + identity: mockIdentityApi, }); expect(activate).toBeFalsy(); diff --git a/plugins/tips/src/lib/extraTips.tsx b/plugins/tips/src/lib/extraTips.tsx index 8dc66aa..7123674 100644 --- a/plugins/tips/src/lib/extraTips.tsx +++ b/plugins/tips/src/lib/extraTips.tsx @@ -1,5 +1,5 @@ import { Tip } from '../config'; -import { hasAnnotation, isEntityOfKind } from './utils'; +import { hasAnnotation, isEntityOfKind, isOwner } from './utils'; import { CodeSnippet } from '@backstage/core-components'; import React from 'react'; import { @@ -17,10 +17,11 @@ export const extraTips: Tip[] = [ content: `You should have some documentation by adding the annotation \`backstage.io/techdocs-ref\`. You can find details at [How to understand techdocs-ref annotation values](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-understand-techdocs-ref-annotation-values). `, - activate: ({ entity }) => - !!entity && + activate: async ({ entity, identity }) => + !!entity && !!identity && isEntityOfKind(entity, ['component', 'api', 'system']) && - !hasAnnotation(entity, 'backstage.io/techdocs-ref'), + !hasAnnotation(entity, 'backstage.io/techdocs-ref') && + isOwner(entity, identity), }, { title: 'Links missing', @@ -41,11 +42,12 @@ links: /> ), - activate: ({ entity }) => - (!!entity && + activate: async ({ entity, identity }) => + ((!!entity && Array.isArray(entity.metadata.links) && entity.metadata.links.length === 0) || - (!!entity && !entity.metadata.links), + (!!entity && !entity.metadata.links)) && + (!!identity && isOwner(entity, identity)), }, { title: 'System missing', @@ -69,10 +71,11 @@ spec: /> ), - activate: ({ entity }) => - !!entity && + activate: async ({ entity, identity }) => + !!entity && !!identity && isEntityOfKind(entity, ['component', 'api', 'resource']) && - !(entity as ComponentEntity | ApiEntity | ResourceEntity).spec.system, + !(entity as ComponentEntity | ApiEntity | ResourceEntity).spec.system && + isOwner(entity, identity), }, { title: 'Members missing', @@ -95,7 +98,7 @@ spec: /> ), - activate: ({ entity }) => + activate: async ({ entity }) => (!!entity && isEntityOfKind(entity, ['group']) && !(entity as GroupEntity).spec.members) || @@ -121,9 +124,10 @@ spec: /> ), - activate: ({ entity }) => - !!entity && + activate: async ({ entity, identity }) => + !!entity && !!identity && isEntityOfKind(entity, ['system']) && - !(entity as SystemEntity).spec.domain, + !(entity as SystemEntity).spec.domain && + isOwner(entity, identity), }, ]; diff --git a/plugins/tips/src/lib/systemModelTips.ts b/plugins/tips/src/lib/systemModelTips.ts index 0b3c4e6..0b1b5f2 100644 --- a/plugins/tips/src/lib/systemModelTips.ts +++ b/plugins/tips/src/lib/systemModelTips.ts @@ -14,7 +14,7 @@ A component can implement APIs for other components to consume. In turn it might or directly depend on components or resources that are attached to it at runtime. More information can be found at [System Model](https://backstage.io/docs/features/software-catalog/system-model#component).`, - activate: ({ entity }) => !!entity && isEntityOfKind(entity, ['component']), + activate: async ({ entity }) => !!entity && isEntityOfKind(entity, ['component']), }, { title: 'API', @@ -32,7 +32,7 @@ indexing and searching all APIs so we can browse them as developers. More information can be found at [System Model](https://backstage.io/docs/features/software-catalog/system-model#api). `, - activate: ({ entity }) => !!entity && isEntityOfKind(entity, ['api']), + activate: async ({ entity }) => !!entity && isEntityOfKind(entity, ['api']), }, { title: 'Resource', @@ -43,7 +43,7 @@ and create tooling around them. More information can be found at [System Model](https://backstage.io/docs/features/software-catalog/system-model#resource). `, - activate: ({ entity }) => !!entity && isEntityOfKind(entity, ['resource']), + activate: async ({ entity }) => !!entity && isEntityOfKind(entity, ['resource']), }, { title: 'User', @@ -51,7 +51,7 @@ More information can be found at [System Model](https://backstage.io/docs/featur More information can be found at [System Model](https://backstage.io/docs/features/software-catalog/system-model#user). `, - activate: ({ entity }) => !!entity && isEntityOfKind(entity, ['user']), + activate: async ({ entity }) => !!entity && isEntityOfKind(entity, ['user']), }, { title: 'Group', @@ -60,7 +60,7 @@ or a loose collection of people in an interest group. More information can be found at [System Model](https://backstage.io/docs/features/software-catalog/system-model#group). `, - activate: ({ entity }) => !!entity && isEntityOfKind(entity, ['group']), + activate: async ({ entity }) => !!entity && isEntityOfKind(entity, ['group']), }, { title: 'System', @@ -78,7 +78,7 @@ and a database to store them. It could expose an RPC API, a daily snapshots data More information can be found at [System Model](https://backstage.io/docs/features/software-catalog/system-model#system). `, - activate: ({ entity }) => !!entity && isEntityOfKind(entity, ['system']), + activate: async ({ entity }) => !!entity && isEntityOfKind(entity, ['system']), }, { title: 'Domain', @@ -91,6 +91,6 @@ Other domains could be “Content Ingestion”, “Ads” or “Search”. More information can be found at [System Model](https://backstage.io/docs/features/software-catalog/system-model#domain). `, - activate: ({ entity }) => !!entity && isEntityOfKind(entity, ['domain']), + activate: async ({ entity }) => !!entity && isEntityOfKind(entity, ['domain']), }, ]; diff --git a/plugins/tips/src/lib/utils.ts b/plugins/tips/src/lib/utils.ts index 4372649..a4a919a 100644 --- a/plugins/tips/src/lib/utils.ts +++ b/plugins/tips/src/lib/utils.ts @@ -15,6 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; +import { IdentityApi } from '@backstage/core-plugin-api'; /** @public */ export const hasAnnotation = (entity: Entity, annotation: string) => @@ -27,3 +28,15 @@ export const isEntityOfKind = ( 'api' | 'user' | 'group' | 'component' | 'resource' | 'system' | 'domain' >, ) => Boolean(kinds.includes(entity.kind.toLowerCase() as any)); + +/** @public */ +export const isOwner = async ( + entity: Entity, + identity: IdentityApi, +) => { + const userIdentity = await identity.getBackstageIdentity(); + if (!['component', 'api', 'resource', 'system', 'domain'].includes(entity.kind.toLowerCase())) { + return false + } + return userIdentity.ownershipEntityRefs.includes((entity.spec as { owner: string }).owner); +} diff --git a/plugins/tips/src/plugin.test.ts b/plugins/tips/src/plugin.test.ts index f992ed6..0f473f2 100644 --- a/plugins/tips/src/plugin.test.ts +++ b/plugins/tips/src/plugin.test.ts @@ -1,18 +1,3 @@ -/* - * Copyright 2022 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 { tipsPlugin } from './plugin'; describe('Tips', () => { diff --git a/plugins/tips/src/plugin.ts b/plugins/tips/src/plugin.ts index 0a01059..1590c6b 100644 --- a/plugins/tips/src/plugin.ts +++ b/plugins/tips/src/plugin.ts @@ -1,19 +1,3 @@ -/* - * Copyright 2022 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 { createApiFactory, createPlugin,