Introduce a new StarredEntitiesApi that is used in the useStarredEntities hook

Signed-off-by: Dominik Henneke <dominik.henneke@sda-se.com>
This commit is contained in:
Dominik Henneke
2021-09-30 15:09:38 +02:00
parent 54bbe25c34
commit 82fbda923e
19 changed files with 637 additions and 157 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-react': minor
'@backstage/plugin-catalog': patch
---
Introduce a new `StarredEntitiesApi` that is used in the `useStarredEntities` hook.
The `@backstage/plugin-catalog` installs a default implementation that is backed by the `StorageApi`, but one can also override the `starredEntitiesApiRef`.
@@ -14,13 +14,17 @@
* limitations under the License.
*/
import React from 'react';
import { EntityLayout } from '@backstage/plugin-catalog';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { cicdContent } from './EntityPage';
import { githubActionsApiRef } from '@backstage/plugin-github-actions';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { EntityLayout } from '@backstage/plugin-catalog';
import {
DefaultStarredEntitiesApi,
EntityProvider,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { githubActionsApiRef } from '@backstage/plugin-github-actions';
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { cicdContent } from './EntityPage';
describe('EntityPage Test', () => {
const entity = {
@@ -48,7 +52,10 @@ describe('EntityPage Test', () => {
downloadJobLogsForWorkflowRun: jest.fn(),
} as jest.Mocked<typeof githubActionsApiRef.T>;
const apis = ApiRegistry.with(githubActionsApiRef, mockedApi);
const apis = ApiRegistry.with(githubActionsApiRef, mockedApi).with(
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }),
);
describe('cicdContent', () => {
it('Should render GitHub Actions View', async () => {
@@ -15,30 +15,31 @@
*/
import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model';
import {
CatalogApi,
catalogApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { apiDocsConfigRef } from '../../config';
import { ApiExplorerPage } from './ApiExplorerPage';
import {
ApiProvider,
ApiRegistry,
ConfigReader,
} from '@backstage/core-app-api';
import { TableColumn, TableProps } from '@backstage/core-components';
import {
storageApiRef,
ConfigApi,
configApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { TableColumn, TableProps } from '@backstage/core-components';
import DashboardIcon from '@material-ui/icons/Dashboard';
import { CatalogTableRow } from '@backstage/plugin-catalog';
import {
CatalogApi,
catalogApiRef,
DefaultStarredEntitiesApi,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { starredEntitiesApiRef } from '@backstage/plugin-catalog-react/src/apis';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import DashboardIcon from '@material-ui/icons/Dashboard';
import { render } from '@testing-library/react';
import React from 'react';
import { apiDocsConfigRef } from '../../config';
import { ApiExplorerPage } from './ApiExplorerPage';
describe('ApiCatalogPage', () => {
const catalogApi: Partial<CatalogApi> = {
@@ -82,6 +83,8 @@ describe('ApiCatalogPage', () => {
getApiDefinitionWidget: () => undefined,
};
const storageApi = MockStorageApi.create();
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
@@ -89,7 +92,11 @@ describe('ApiCatalogPage', () => {
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[configApiRef, configApi],
[storageApiRef, MockStorageApi.create()],
[storageApiRef, storageApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({ storageApi }),
],
[apiDocsConfigRef, apiDocsConfig],
])}
>
+40
View File
@@ -16,11 +16,13 @@ import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
import { IconButton } from '@material-ui/core';
import { LinkProps } from '@backstage/core-components';
import { Observable } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { StorageApi } from '@backstage/core-plugin-api';
import { SystemEntity } from '@backstage/catalog-model';
import { TableColumn } from '@backstage/core-components';
import { UserEntity } from '@backstage/catalog-model';
@@ -131,6 +133,23 @@ export type DefaultEntityFilters = {
text?: EntityTextFilter;
};
// Warning: (ae-missing-release-tag) "DefaultStarredEntitiesApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class DefaultStarredEntitiesApi implements StarredEntitiesApi {
constructor(opts: { storageApi: StorageApi });
// (undocumented)
isStarred(entity: Entity): boolean;
// (undocumented)
star(entity: Entity): Promise<void>;
// (undocumented)
starredEntities$(): Observable<StarredEntitiesApiObservable>;
// (undocumented)
toggleStarred(entity: Entity): Promise<void>;
// (undocumented)
unstar(entity: Entity): Promise<void>;
}
// Warning: (ae-forgotten-export) The symbol "EntityLoadingStatus" needs to be exported by the entry point index.d.ts
//
// @public @deprecated (undocumented)
@@ -742,6 +761,27 @@ export function reduceEntityFilters(
// @public (undocumented)
export const rootRoute: RouteRef<undefined>;
// @public
export interface StarredEntitiesApi {
star(entity: Entity): Promise<void>;
starredEntities$(): Observable<StarredEntitiesApiObservable>;
toggleStarred(entity: Entity): Promise<void>;
unstar(entity: Entity): Promise<void>;
}
// Warning: (ae-missing-release-tag) "StarredEntitiesApiObservable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type StarredEntitiesApiObservable = {
starredEntities: Set<string>;
isStarred: (entity: Entity) => boolean;
};
// Warning: (ae-missing-release-tag) "starredEntitiesApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const starredEntitiesApiRef: ApiRef<StarredEntitiesApi>;
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "UnregisterEntityDialog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
+3 -1
View File
@@ -45,7 +45,8 @@
"qs": "^6.9.4",
"react": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-use": "^17.2.4"
"react-use": "^17.2.4",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.7.15",
@@ -56,6 +57,7 @@
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/jwt-decode": "^3.1.0",
"@types/zen-observable": "^0.8.0",
"cross-fetch": "^3.0.6",
"react-test-renderer": "^16.13.1"
},
@@ -0,0 +1,145 @@
/*
* 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 } from '@backstage/catalog-model';
import { StorageApi } from '@backstage/core-plugin-api';
import { MockStorageApi } from '@backstage/test-utils';
import { DefaultStarredEntitiesApi } from './DefaultStarredEntitiesApi';
describe('DefaultStarredEntitiesApi', () => {
let mockStorage: StorageApi;
let starredEntitiesApi: DefaultStarredEntitiesApi;
const mockEntity: Entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'mock',
},
};
beforeEach(() => {
mockStorage = MockStorageApi.create();
starredEntitiesApi = new DefaultStarredEntitiesApi({
storageApi: mockStorage,
});
});
afterEach(() => {
jest.resetAllMocks();
});
describe('toggleStarred', () => {
it('should star unstarred entity', async () => {
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false);
await starredEntitiesApi.toggleStarred(mockEntity);
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true);
});
it('should unstar starred entity', async () => {
const bucket = mockStorage.forBucket('settings');
await bucket.set('starredEntities', ['entity:Component:default:mock']);
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true);
await starredEntitiesApi.toggleStarred(mockEntity);
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false);
});
});
describe('star', () => {
it('should star unstarred entity', async () => {
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false);
await starredEntitiesApi.star(mockEntity);
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true);
});
it('should keep starred entity', async () => {
const bucket = mockStorage.forBucket('settings');
await bucket.set('starredEntities', ['entity:Component:default:mock']);
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true);
await starredEntitiesApi.star(mockEntity);
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true);
});
});
describe('unstar', () => {
it('should unstar starred entity', async () => {
const bucket = mockStorage.forBucket('settings');
await bucket.set('starredEntities', ['entity:Component:default:mock']);
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(true);
await starredEntitiesApi.unstar(mockEntity);
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false);
});
it('should keep unstarred entity', async () => {
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false);
await starredEntitiesApi.unstar(mockEntity);
expect(starredEntitiesApi.isStarred(mockEntity)).toBe(false);
});
});
describe('starredEntities$', () => {
const handler = jest.fn();
beforeEach(async () => {
await new Promise<void>(resolve => {
starredEntitiesApi.starredEntities$().subscribe({
next: (...args) => {
handler(...args);
if (handler.mock.calls.length >= 2) {
resolve();
}
},
});
const bucket = mockStorage.forBucket('settings');
bucket.set('starredEntities', ['entity:Component:default:mock']).then();
});
});
it('should receive updates', async () => {
expect(handler).toBeCalledTimes(2);
expect(handler).toBeCalledWith({
starredEntities: new Set(),
isStarred: expect.any(Function),
});
expect(handler).toBeCalledWith({
starredEntities: new Set(['entity:Component:default:mock']),
isStarred: expect.any(Function),
});
});
it('should receive isStarred function that operates on the latest state', async () => {
expect(handler.mock.calls[0][0].isStarred(mockEntity)).toBe(true);
expect(handler.mock.calls[1][0].isStarred(mockEntity)).toBe(true);
});
});
});
@@ -0,0 +1,116 @@
/*
* 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 } from '@backstage/catalog-model';
import { Observable, StorageApi } from '@backstage/core-plugin-api';
import ObservableImpl from 'zen-observable';
import {
StarredEntitiesApi,
StarredEntitiesApiObservable,
} from './StarredEntitiesApi';
const buildEntityKey = (component: Entity) =>
`entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${
component.metadata.name
}`;
export class DefaultStarredEntitiesApi implements StarredEntitiesApi {
private readonly settingsStore: StorageApi;
private starredEntities: Set<string>;
constructor(opts: { storageApi: StorageApi }) {
this.settingsStore = opts.storageApi.forBucket('settings');
this.starredEntities = new Set(
this.settingsStore.get<string[]>('starredEntities') ?? [],
);
this.settingsStore.observe$<string[]>('starredEntities').subscribe({
next: next => {
this.starredEntities = new Set(next.newValue ?? []);
this.notifyChanges();
},
});
}
async toggleStarred(entity: Entity): Promise<void> {
const entityKey = buildEntityKey(entity);
if (this.starredEntities.has(entityKey)) {
await this.unstar(entity);
} else {
await this.star(entity);
}
}
async star(entity: Entity): Promise<void> {
const entityKey = buildEntityKey(entity);
this.starredEntities.add(entityKey);
await this.settingsStore.set(
'starredEntities',
Array.from(this.starredEntities),
);
}
async unstar(entity: Entity): Promise<void> {
const entityKey = buildEntityKey(entity);
this.starredEntities.delete(entityKey);
await this.settingsStore.set(
'starredEntities',
Array.from(this.starredEntities),
);
}
starredEntities$(): Observable<StarredEntitiesApiObservable> {
return this.observable;
}
isStarred(entity: Entity): boolean {
const entityKey = buildEntityKey(entity);
return this.starredEntities.has(entityKey);
}
private readonly subscribers = new Set<
ZenObservable.SubscriptionObserver<StarredEntitiesApiObservable>
>();
private readonly observable =
new ObservableImpl<StarredEntitiesApiObservable>(subscriber => {
// forward the the latest value
subscriber.next({
starredEntities: this.starredEntities,
isStarred: e => this.isStarred(e),
});
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
private notifyChanges() {
for (const subscription of this.subscribers) {
subscription.next({
starredEntities: this.starredEntities,
isStarred: e => this.isStarred(e),
});
}
}
}
@@ -0,0 +1,71 @@
/*
* 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 } from '@backstage/catalog-model';
import { ApiRef, createApiRef, Observable } from '@backstage/core-plugin-api';
export const starredEntitiesApiRef: ApiRef<StarredEntitiesApi> = createApiRef({
id: 'catalog-react.starred-entities',
});
export type StarredEntitiesApiObservable = {
/**
* A set of starred entities
*/
starredEntities: Set<string>;
/**
* A function to check if an entity is starred.
*
* @param entity - the entity to check
* @returns true, if the entity is starred.
*/
isStarred: (entity: Entity) => boolean;
};
/**
* An API to store and retrieve starred entities
*
* @public
*/
export interface StarredEntitiesApi {
/**
* Toggle the star state of the entity
*
* @param entity - the entity to be toggled
*/
toggleStarred(entity: Entity): Promise<void>;
/**
* Star the entity
*
* @param entity - the entity to be starred
*/
star(entity: Entity): Promise<void>;
/**
* Unstar the entity
*
* @param entity - the entity to be unstarred
*/
unstar(entity: Entity): Promise<void>;
/**
* Observe the state of starred entities and receive a handler
* to check the star state of an entity.
*/
starredEntities$(): Observable<StarredEntitiesApiObservable>;
}
@@ -0,0 +1,22 @@
/*
* 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.
*/
export { DefaultStarredEntitiesApi } from './DefaultStarredEntitiesApi';
export { starredEntitiesApiRef } from './StarredEntitiesApi';
export type {
StarredEntitiesApi,
StarredEntitiesApiObservable,
} from './StarredEntitiesApi';
+17
View File
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './StarredEntitiesApi';
@@ -14,21 +14,8 @@
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import qs from 'qs';
import { act, renderHook } from '@testing-library/react-hooks';
import { MockStorageApi } from '@backstage/test-utils';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import {
EntityListProvider,
useEntityListProvider,
} from './useEntityListProvider';
import { catalogApiRef } from '../api';
import { UserListFilterKind } from '../types';
import { EntityKindFilter, EntityTypeFilter, UserListFilter } from '../filters';
import { EntityKindPicker, UserListPicker } from '../components';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import {
ConfigApi,
@@ -37,6 +24,19 @@ import {
identityApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { MockStorageApi } from '@backstage/test-utils';
import { act, renderHook } from '@testing-library/react-hooks';
import qs from 'qs';
import React, { PropsWithChildren } from 'react';
import { catalogApiRef } from '../api';
import { DefaultStarredEntitiesApi, starredEntitiesApiRef } from '../apis';
import { EntityKindPicker, UserListPicker } from '../components';
import { EntityKindFilter, EntityTypeFilter, UserListFilter } from '../filters';
import { UserListFilterKind } from '../types';
import {
EntityListProvider,
useEntityListProvider,
} from './useEntityListProvider';
const entities: Entity[] = [
{
@@ -81,6 +81,10 @@ const apis = ApiRegistry.from([
[catalogApiRef, mockCatalogApi],
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }),
],
]);
const wrapper = ({
@@ -14,16 +14,18 @@
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useStarredEntities } from './useStarredEntities';
import { storageApiRef, StorageApi } from '@backstage/core-plugin-api';
import { MockErrorApi } from '@backstage/test-utils';
import { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry, WebStorage } from '@backstage/core-app-api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { StorageApi } from '@backstage/core-plugin-api';
import { MockStorageApi } from '@backstage/test-utils';
import { act, renderHook } from '@testing-library/react-hooks';
import React, { PropsWithChildren } from 'react';
import { DefaultStarredEntitiesApi, starredEntitiesApiRef } from '../apis';
import { useStarredEntities } from './useStarredEntities';
describe('useStarredEntities', () => {
let mockStorage: StorageApi | undefined;
let mockStorage: StorageApi;
let wrapper: React.ComponentType;
const mockEntity: Entity = {
apiVersion: '1',
@@ -42,41 +44,56 @@ describe('useStarredEntities', () => {
},
};
const wrapper = ({ children }: PropsWithChildren<{}>) => {
return (
<ApiProvider apis={ApiRegistry.with(storageApiRef, mockStorage)}>
beforeEach(() => {
mockStorage = MockStorageApi.create();
wrapper = ({ children }: PropsWithChildren<{}>) => (
<ApiProvider
apis={ApiRegistry.with(
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({ storageApi: mockStorage }),
)}
>
{children}
</ApiProvider>
);
};
beforeEach(() => {
mockStorage = new WebStorage('@backstage', new MockErrorApi()).forBucket(
Date.now().toString(), // TODO(blam): need something that changes every test run for now until the MockStorage is implemented
);
});
it('should return an empty set for when there is no items in storage', async () => {
const { result } = renderHook(() => useStarredEntities(), { wrapper });
const { result, waitForNextUpdate } = renderHook(
() => useStarredEntities(),
{ wrapper },
);
await waitForNextUpdate();
expect(result.current.starredEntities.size).toBe(0);
});
it('should return a set with the current items when there are items in storage', async () => {
const expectedIds = ['i', 'am', 'some', 'test', 'ids'];
const store = mockStorage?.forBucket('settings');
await store?.set('starredEntities', expectedIds);
const { result } = renderHook(() => useStarredEntities(), { wrapper });
const { result, waitForNextUpdate } = renderHook(
() => useStarredEntities(),
{ wrapper },
);
await waitForNextUpdate();
for (const item of expectedIds) {
expect(result.current.starredEntities.has(item)).toBeTruthy();
}
});
it('should listen to changes when the storage is set elsewhere', async () => {
const { result, waitForNextUpdate } = renderHook(
() => useStarredEntities(),
{ wrapper },
);
await waitForNextUpdate();
expect(result.current.starredEntities.size).toBe(0);
expect(result.current.isStarredEntity(mockEntity)).toBeFalsy();
@@ -91,18 +108,26 @@ describe('useStarredEntities', () => {
});
it('should write new entries to the local store when adding a toggling entity', async () => {
const { result } = renderHook(() => useStarredEntities(), { wrapper });
const { result, waitForNextUpdate } = renderHook(
() => useStarredEntities(),
{ wrapper },
);
act(() => {
result.current.toggleStarredEntity(mockEntity);
});
await waitForNextUpdate();
expect(result.current.isStarredEntity(mockEntity)).toBeTruthy();
expect(result.current.isStarredEntity(secondMockEntity)).toBeFalsy();
});
it('should remove an existing entity when toggling entries', async () => {
const { result } = renderHook(() => useStarredEntities(), { wrapper });
const { result, waitForNextUpdate } = renderHook(
() => useStarredEntities(),
{ wrapper },
);
act(() => {
result.current.toggleStarredEntity(mockEntity);
@@ -110,6 +135,8 @@ describe('useStarredEntities', () => {
result.current.toggleStarredEntity(mockEntity);
});
await waitForNextUpdate();
expect(result.current.isStarredEntity(mockEntity)).toBeFalsy();
expect(result.current.isStarredEntity(secondMockEntity)).toBeTruthy();
});
@@ -15,56 +15,24 @@
*/
import { Entity } from '@backstage/catalog-model';
import { storageApiRef, useApi } from '@backstage/core-plugin-api';
import { useCallback, useEffect, useState } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import { useCallback } from 'react';
import { useObservable } from 'react-use';
const buildEntityKey = (component: Entity) =>
`entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${
component.metadata.name
}`;
import { starredEntitiesApiRef } from '../apis';
export const useStarredEntities = () => {
const storageApi = useApi(storageApiRef);
const settingsStore = storageApi.forBucket('settings');
const rawStarredEntityKeys =
settingsStore.get<string[]>('starredEntities') ?? [];
const starredEntitiesApi = useApi(starredEntitiesApiRef);
const [starredEntities, setStarredEntities] = useState(
new Set(rawStarredEntityKeys),
const { starredEntities, isStarred: isStarredEntity } = useObservable(
starredEntitiesApi.starredEntities$(),
{ starredEntities: new Set<string>(), isStarred: _ => false },
);
const observedItems = useObservable(
settingsStore.observe$<string[]>('starredEntities'),
);
useEffect(() => {
if (observedItems?.newValue) {
const currentValue = observedItems?.newValue ?? [];
setStarredEntities(new Set(currentValue));
}
}, [observedItems?.newValue]);
const toggleStarredEntity = useCallback(
(entity: Entity) => {
const entityKey = buildEntityKey(entity);
if (starredEntities.has(entityKey)) {
starredEntities.delete(entityKey);
} else {
starredEntities.add(entityKey);
}
settingsStore.set('starredEntities', Array.from(starredEntities));
starredEntitiesApi.toggleStarred(entity).then();
},
[starredEntities, settingsStore],
);
const isStarredEntity = useCallback(
(entity: Entity) => {
const entityKey = buildEntityKey(entity);
return starredEntities.has(entityKey);
},
[starredEntities],
[starredEntitiesApi],
);
return {
+1
View File
@@ -23,6 +23,7 @@
export type { CatalogApi } from '@backstage/catalog-client';
export { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client';
export { catalogApiRef } from './api';
export * from './apis';
export * from './components';
export * from './hooks';
export * from './filters';
@@ -20,28 +20,32 @@ import {
RELATION_MEMBER_OF,
RELATION_OWNED_BY,
} from '@backstage/catalog-model';
import { TableColumn, TableProps } from '@backstage/core-components';
import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react';
import {
MockStorageApi,
renderWithEffects,
wrapInTestApp,
mockBreakpoint,
} from '@backstage/test-utils';
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { createComponentRouteRef } from '../../routes';
import { EntityRow } from '../CatalogTable/types';
import { CatalogPage } from './CatalogPage';
import DashboardIcon from '@material-ui/icons/Dashboard';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { TableColumn, TableProps } from '@backstage/core-components';
import {
IdentityApi,
identityApiRef,
ProfileInfo,
storageApiRef,
} from '@backstage/core-plugin-api';
import {
catalogApiRef,
DefaultStarredEntitiesApi,
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import {
mockBreakpoint,
MockStorageApi,
renderWithEffects,
wrapInTestApp,
} from '@backstage/test-utils';
import DashboardIcon from '@material-ui/icons/Dashboard';
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { createComponentRouteRef } from '../../routes';
import { EntityRow } from '../CatalogTable';
import { CatalogPage } from './CatalogPage';
describe('CatalogPage', () => {
const origReplaceState = window.history.replaceState;
@@ -120,6 +124,7 @@ describe('CatalogPage', () => {
getIdToken: async () => undefined,
getProfile: () => testProfile,
};
const storageApi = MockStorageApi.create();
const renderWrapped = (children: React.ReactNode) =>
renderWithEffects(
@@ -128,7 +133,11 @@ describe('CatalogPage', () => {
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
[storageApiRef, storageApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({ storageApi }),
],
])}
>
{children}
@@ -15,19 +15,22 @@
*/
import {
EDIT_URL_ANNOTATION,
Entity,
VIEW_URL_ANNOTATION,
EDIT_URL_ANNOTATION,
} from '@backstage/catalog-model';
import { act, fireEvent } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import * as React from 'react';
import { CatalogTable } from './CatalogTable';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import {
entityRouteRef,
DefaultStarredEntitiesApi,
MockEntityListContextProvider,
starredEntitiesApiRef,
UserListFilter,
} from '@backstage/plugin-catalog-react';
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import * as React from 'react';
import { CatalogTable } from './CatalogTable';
const entities: Entity[] = [
{
@@ -48,6 +51,11 @@ const entities: Entity[] = [
];
describe('CatalogTable component', () => {
const mockApis = ApiRegistry.with(
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }),
);
beforeEach(() => {
window.open = jest.fn();
});
@@ -58,9 +66,11 @@ describe('CatalogTable component', () => {
it('should render error message', async () => {
const rendered = await renderInTestApp(
<MockEntityListContextProvider value={{ error: new Error('error') }}>
<CatalogTable />
</MockEntityListContextProvider>,
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider value={{ error: new Error('error') }}>
<CatalogTable />
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
@@ -75,20 +85,22 @@ describe('CatalogTable component', () => {
it('should display entity names when loading has finished and no error occurred', async () => {
const rendered = await renderInTestApp(
<MockEntityListContextProvider
value={{
entities,
filters: {
user: new UserListFilter(
'owned',
() => false,
() => false,
),
},
}}
>
<CatalogTable />
</MockEntityListContextProvider>,
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider
value={{
entities,
filters: {
user: new UserListFilter(
'owned',
() => false,
() => false,
),
},
}}
>
<CatalogTable />
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
@@ -112,9 +124,11 @@ describe('CatalogTable component', () => {
};
const { getByTitle } = await renderInTestApp(
<MockEntityListContextProvider value={{ entities: [entity] }}>
<CatalogTable />
</MockEntityListContextProvider>,
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider value={{ entities: [entity] }}>
<CatalogTable />
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
@@ -142,9 +156,11 @@ describe('CatalogTable component', () => {
};
const { getByTitle } = await renderInTestApp(
<MockEntityListContextProvider value={{ entities: [entity] }}>
<CatalogTable />
</MockEntityListContextProvider>,
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider value={{ entities: [entity] }}>
<CatalogTable />
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
@@ -13,24 +13,26 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
import {
catalogApiRef,
EntityProvider,
AsyncEntityProvider,
catalogApiRef,
DefaultStarredEntitiesApi,
EntityProvider,
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route, Routes } from 'react-router';
import { EntityLayout } from './EntityLayout';
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
const mockEntity = {
kind: 'MyKind',
metadata: {
@@ -38,10 +40,12 @@ const mockEntity = {
},
} as Entity;
const mockApis = ApiRegistry.with(catalogApiRef, {} as CatalogApi).with(
alertApiRef,
{} as AlertApi,
);
const mockApis = ApiRegistry.with(catalogApiRef, {} as CatalogApi)
.with(alertApiRef, {} as AlertApi)
.with(
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }),
);
describe('EntityLayout', () => {
it('renders simplest case', async () => {
+10
View File
@@ -18,7 +18,9 @@ import { CatalogClient } from '@backstage/catalog-client';
import {
catalogApiRef,
catalogRouteRef,
DefaultStarredEntitiesApi,
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { CatalogClientWrapper } from './CatalogClientWrapper';
import { createComponentRouteRef, viewTechDocRouteRef } from './routes';
@@ -29,6 +31,7 @@ import {
createRoutableExtension,
discoveryApiRef,
identityApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
export const catalogPlugin = createPlugin({
@@ -43,6 +46,13 @@ export const catalogPlugin = createPlugin({
identityApi,
}),
}),
createApiFactory({
api: starredEntitiesApiRef,
deps: { storageApi: storageApiRef },
factory: ({ storageApi }) =>
new DefaultStarredEntitiesApi({ storageApi }),
}),
],
routes: {
catalogIndex: catalogRouteRef,
@@ -14,12 +14,6 @@
* limitations under the License.
*/
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { DefaultTechDocsHome } from './DefaultTechDocsHome';
import {
ApiProvider,
ApiRegistry,
@@ -30,7 +24,17 @@ import {
configApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import {
CatalogApi,
catalogApiRef,
DefaultStarredEntitiesApi,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { rootDocsRouteRef } from '../../routes';
import { DefaultTechDocsHome } from './DefaultTechDocsHome';
jest.mock('@backstage/plugin-catalog-react', () => {
const actual = jest.requireActual('@backstage/plugin-catalog-react');
@@ -63,10 +67,13 @@ describe('TechDocs Home', () => {
},
});
const storageApi = MockStorageApi.create();
const apiRegistry = ApiRegistry.from([
[catalogApiRef, mockCatalogApi],
[configApiRef, configApi],
[storageApiRef, MockStorageApi.create()],
[starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi })],
]);
it('should render a TechDocs home page', async () => {