Merge pull request #7410 from SDA-SE/feat/starredentitiesapi

Introduce a new `StarredEntitiesApi` that is used in the `useStarredEntities` hook
This commit is contained in:
Patrik Oldsberg
2021-10-14 14:39:19 +02:00
committed by GitHub
30 changed files with 964 additions and 198 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@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`.
This change also updates the storage format from a custom string to an entity reference and moves the location in the local storage.
A migration will convert the previously starred entities to the location on the first load of Backstage.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-scaffolder': patch
---
Introduce a `useStarredEntity` hook to check if a single entity is starred.
It provides a more efficient implementation compared to the `useStarredEntities` hook, because the rendering is only triggered if the selected entity is starred, not if _any_ entity is starred.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/test-utils': patch
---
Store the namespaced bucket storage for each instance that was created with `MockStorage.create()` instead of global variable.
@@ -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 () => {
@@ -13,8 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MockStorageApi } from './MockStorageApi';
import { StorageApi } from '@backstage/core-plugin-api';
import { MockStorageApi } from './MockStorageApi';
describe('WebStorage Storage API', () => {
const createMockStorage = (): StorageApi => {
@@ -124,7 +124,7 @@ describe('WebStorage Storage API', () => {
expect(secondStorage.get(keyName)).toBe('deerp');
});
it('should not clash with other namesapces when creating buckets', async () => {
it('should not clash with other namespaces when creating buckets', async () => {
const rootStorage = createMockStorage();
// when getting key test2 it will translate to /profile/something/deep/test2
@@ -139,4 +139,17 @@ describe('WebStorage Storage API', () => {
expect(secondStorage.get('deep/test2')).toBe(undefined);
});
it('should not reuse storage instances between different rootStorages', async () => {
const rootStorage1 = createMockStorage();
const rootStorage2 = createMockStorage();
const firstStorage = rootStorage1.forBucket('something');
const secondStorage = rootStorage2.forBucket('something');
await firstStorage.set('test2', true);
expect(firstStorage.get('test2')).toBe(true);
expect(secondStorage.get('test2')).toBe(undefined);
});
});
@@ -23,29 +23,37 @@ import ObservableImpl from 'zen-observable';
export type MockStorageBucket = { [key: string]: any };
const bucketStorageApis = new Map<string, MockStorageApi>();
export class MockStorageApi implements StorageApi {
private readonly namespace: string;
private readonly data: MockStorageBucket;
private readonly bucketStorageApis: Map<string, MockStorageApi>;
private constructor(namespace: string, data?: MockStorageBucket) {
private constructor(
namespace: string,
bucketStorageApis: Map<string, MockStorageApi>,
data?: MockStorageBucket,
) {
this.namespace = namespace;
this.bucketStorageApis = bucketStorageApis;
this.data = { ...data };
}
static create(data?: MockStorageBucket) {
return new MockStorageApi('', data);
return new MockStorageApi('', new Map(), data);
}
forBucket(name: string): StorageApi {
if (!bucketStorageApis.has(name)) {
bucketStorageApis.set(
if (!this.bucketStorageApis.has(name)) {
this.bucketStorageApis.set(
name,
new MockStorageApi(`${this.namespace}/${name}`, this.data),
new MockStorageApi(
`${this.namespace}/${name}`,
this.bucketStorageApis,
this.data,
),
);
}
return bucketStorageApis.get(name)!;
return this.bucketStorageApis.get(name)!;
}
get<T>(key: string): T | undefined {
@@ -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,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
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],
])}
>
+33 -3
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,17 @@ export type DefaultEntityFilters = {
text?: EntityTextFilter;
};
// @public
export class DefaultStarredEntitiesApi implements StarredEntitiesApi {
constructor(opts: { storageApi: StorageApi });
// (undocumented)
isStarred(entityRef: string): boolean;
// (undocumented)
starredEntitie$(): Observable<Set<string>>;
// (undocumented)
toggleStarred(entityRef: string): 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 +755,15 @@ export function reduceEntityFilters(
// @public (undocumented)
export const rootRoute: RouteRef<undefined>;
// @public
export interface StarredEntitiesApi {
starredEntitie$(): Observable<Set<string>>;
toggleStarred(entityRef: string): Promise<void>;
}
// @public
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)
//
@@ -866,10 +888,18 @@ export const UserListPicker: ({
// Warning: (ae-missing-release-tag) "useStarredEntities" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const useStarredEntities: () => {
export function useStarredEntities(): {
starredEntities: Set<string>;
toggleStarredEntity: (entity: Entity) => void;
isStarredEntity: (entity: Entity) => boolean;
toggleStarredEntity: (entityOrRef: Entity | EntityName | string) => void;
isStarredEntity: (entityOrRef: Entity | EntityName | string) => boolean;
};
// Warning: (ae-missing-release-tag) "useStarredEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function useStarredEntity(entityOrRef: Entity | EntityName | string): {
toggleStarredEntity: () => void;
isStarredEntity: boolean;
};
// Warnings were encountered during analysis:
+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,103 @@
/*
* 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 { stringifyEntityRef } from '@backstage/catalog-model';
import { StorageApi } from '@backstage/core-plugin-api';
import { MockStorageApi } from '@backstage/test-utils';
import { DefaultStarredEntitiesApi } from './DefaultStarredEntitiesApi';
import { performMigrationToTheNewBucket } from './migration';
jest.mock('./migration');
describe('DefaultStarredEntitiesApi', () => {
let mockStorage: StorageApi;
let starredEntitiesApi: DefaultStarredEntitiesApi;
const mockEntityRef = stringifyEntityRef({
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'mock',
},
});
beforeEach(() => {
(performMigrationToTheNewBucket as jest.Mock).mockResolvedValue(undefined);
mockStorage = MockStorageApi.create();
starredEntitiesApi = new DefaultStarredEntitiesApi({
storageApi: mockStorage,
});
});
afterEach(() => {
jest.resetAllMocks();
});
describe('constructor', () => {
it('should call migration', () => {
expect(performMigrationToTheNewBucket).toBeCalledTimes(1);
});
});
describe('toggleStarred', () => {
it('should star unstarred entity', async () => {
expect(starredEntitiesApi.isStarred(mockEntityRef)).toBe(false);
await starredEntitiesApi.toggleStarred(mockEntityRef);
expect(starredEntitiesApi.isStarred(mockEntityRef)).toBe(true);
});
it('should unstar starred entity', async () => {
const bucket = mockStorage.forBucket('starredEntities');
await bucket.set('entityRefs', ['component:default/mock']);
expect(starredEntitiesApi.isStarred(mockEntityRef)).toBe(true);
await starredEntitiesApi.toggleStarred(mockEntityRef);
expect(starredEntitiesApi.isStarred(mockEntityRef)).toBe(false);
});
});
describe('starredEntities$', () => {
const handler = jest.fn();
beforeEach(async () => {
await new Promise<void>(resolve => {
starredEntitiesApi.starredEntitie$().subscribe({
next: (...args) => {
handler(...args);
if (handler.mock.calls.length >= 2) {
resolve();
}
},
});
const bucket = mockStorage.forBucket('starredEntities');
bucket.set('entityRefs', ['component:default/mock']).then();
});
});
it('should receive updates', async () => {
expect(handler).toBeCalledTimes(2);
expect(handler).toBeCalledWith(new Set());
expect(handler).toBeCalledWith(new Set(['component:default/mock']));
});
});
});
@@ -0,0 +1,89 @@
/*
* 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 { Observable, StorageApi } from '@backstage/core-plugin-api';
import ObservableImpl from 'zen-observable';
import { performMigrationToTheNewBucket } from './migration';
import { StarredEntitiesApi } from './StarredEntitiesApi';
/**
* Default implementation of the StarredEntitiesApi that is backed by the StorageApi.
*
* @public
*/
export class DefaultStarredEntitiesApi implements StarredEntitiesApi {
private readonly settingsStore: StorageApi;
private starredEntities: Set<string>;
constructor(opts: { storageApi: StorageApi }) {
// no need to await. The updated content will be caught by the observe$
performMigrationToTheNewBucket(opts).then();
this.settingsStore = opts.storageApi.forBucket('starredEntities');
this.starredEntities = new Set(
this.settingsStore.get<string[]>('entityRefs') ?? [],
);
this.settingsStore.observe$<string[]>('entityRefs').subscribe({
next: next => {
this.starredEntities = new Set(next.newValue ?? []);
this.notifyChanges();
},
});
}
async toggleStarred(entityRef: string): Promise<void> {
if (this.starredEntities.has(entityRef)) {
this.starredEntities.delete(entityRef);
} else {
this.starredEntities.add(entityRef);
}
await this.settingsStore.set(
'entityRefs',
Array.from(this.starredEntities),
);
}
starredEntitie$(): Observable<Set<string>> {
return this.observable;
}
isStarred(entityRef: string): boolean {
return this.starredEntities.has(entityRef);
}
private readonly subscribers = new Set<
ZenObservable.SubscriptionObserver<Set<string>>
>();
private readonly observable = new ObservableImpl<Set<string>>(subscriber => {
// forward the the latest value
subscriber.next(this.starredEntities);
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
private notifyChanges() {
for (const subscription of this.subscribers) {
subscription.next(this.starredEntities);
}
}
}
@@ -0,0 +1,45 @@
/*
* 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 { ApiRef, createApiRef, Observable } from '@backstage/core-plugin-api';
/**
* An API to store starred entities
*
* @public
*/
export const starredEntitiesApiRef: ApiRef<StarredEntitiesApi> = createApiRef({
id: 'catalog-react.starred-entities',
});
/**
* An API to store and retrieve starred entities
*
* @public
*/
export interface StarredEntitiesApi {
/**
* Toggle the star state of the entity
*
* @param entityRef - an entity reference to toggle
*/
toggleStarred(entityRef: string): Promise<void>;
/**
* Observe the set of starred entity references.
*/
starredEntitie$(): Observable<Set<string>>;
}
@@ -0,0 +1,19 @@
/*
* 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 } from './StarredEntitiesApi';
@@ -0,0 +1,113 @@
/*
* 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 { StorageApi } from '@backstage/core-plugin-api';
import { MockStorageApi } from '@backstage/test-utils';
import { performMigrationToTheNewBucket } from './migration';
describe('performMigrationToTheNewBucket', () => {
let mockStorage: StorageApi;
beforeEach(() => {
mockStorage = MockStorageApi.create();
});
afterEach(() => {
jest.resetAllMocks();
});
it('should migrate', async () => {
const oldBucket = mockStorage.forBucket('settings');
const newBucket = mockStorage.forBucket('starredEntities');
// fill NEW bucket
await newBucket.set('entityRefs', ['component:default/c']);
// fill OLD bucket
await oldBucket.set('starredEntities', [
'entity:Component:default:a',
'entity:template:custom:b',
]);
expect(oldBucket.get('starredEntities')).not.toBeUndefined();
await performMigrationToTheNewBucket({ storageApi: mockStorage });
// read NEW bucket
expect(await newBucket.get('entityRefs')).toEqual([
'component:default/c',
'component:default/a',
'template:custom/b',
]);
// OLD bucket should be removed
expect(oldBucket.get('starredEntities')).toBeUndefined();
});
it('should ignore invalid entries', async () => {
const oldBucket = mockStorage.forBucket('settings');
const newBucket = mockStorage.forBucket('starredEntities');
// fill OLD bucket
await oldBucket.set('starredEntities', [
'entity:Component:default:a',
1,
'entity:Component:a',
'invalid',
]);
expect(oldBucket.get('starredEntities')).not.toBeUndefined();
await performMigrationToTheNewBucket({ storageApi: mockStorage });
// read NEW bucket
expect(await newBucket.get('entityRefs')).toEqual(['component:default/a']);
// OLD bucket should be removed
expect(oldBucket.get('starredEntities')).toBeUndefined();
});
it('should skip migration without old starred entities', async () => {
const newBucket = mockStorage.forBucket('starredEntities');
// fill NEW bucket
const expectedEntries = ['component:default/a'];
await newBucket.set('entityRefs', expectedEntries);
await performMigrationToTheNewBucket({ storageApi: mockStorage });
// read NEW bucket
expect(newBucket.get('entityRefs')).toBe(expectedEntries);
});
it('should skip migration with non-array old starred entities', async () => {
const oldBucket = mockStorage.forBucket('settings');
const newBucket = mockStorage.forBucket('starredEntities');
// fill OLD bucket with invalid content
await oldBucket.set('starredEntities', 'invalid');
// fill NEW bucket
const expectedEntries = ['component:default/a'];
await newBucket.set('entityRefs', expectedEntries);
await performMigrationToTheNewBucket({ storageApi: mockStorage });
// read NEW bucket
expect(newBucket.get('entityRefs')).toBe(expectedEntries);
// OLD bucket should be unchanged
expect(oldBucket.get('starredEntities')).toBe('invalid');
});
});
@@ -0,0 +1,62 @@
/*
* 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 { stringifyEntityRef } from '@backstage/catalog-model';
import { StorageApi } from '@backstage/core-plugin-api';
import { isArray, isString } from 'lodash';
/**
* Migrate the starred entities from the old format (entity:<kind>:<namespace>:<name>) from the
* old storage location (/settings/starredEntities) to entity references in the new location
* (/starredEntities/entityRefs).
*
* This will only be executed once since the old location is cleared.
*
* @param storageApi - the StorageApi to migrate
*/
export async function performMigrationToTheNewBucket({
storageApi,
}: {
storageApi: StorageApi;
}) {
const source = storageApi.forBucket('settings');
const target = storageApi.forBucket('starredEntities');
const oldStarredEntities = source.get('starredEntities');
if (!isArray(oldStarredEntities)) {
// nothing to do
return;
}
const targetEntities = new Set(target.get('entityRefs') ?? []);
oldStarredEntities
.filter(isString)
// extract the old format 'entity:<kind>:<namespace>:<name>'
.map(old => old.split(':'))
// check if the format is valid
.filter(split => split.length === 4 && split[0] === 'entity')
// convert to entity references
.map(([_, kind, namespace, name]) =>
stringifyEntityRef({ kind, namespace, name }),
)
.forEach(e => targetEntities.add(e));
await target.set('entityRefs', Array.from(targetEntities));
await source.remove('starredEntities');
}
+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,12 +14,12 @@
* limitations under the License.
*/
import React, { ComponentProps } from 'react';
import { useStarredEntities } from '../../hooks/useStarredEntities';
import { IconButton, Tooltip, withStyles } from '@material-ui/core';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { Entity } from '@backstage/catalog-model';
import { IconButton, Tooltip, withStyles } from '@material-ui/core';
import Star from '@material-ui/icons/Star';
import StarBorder from '@material-ui/icons/StarBorder';
import React, { ComponentProps } from 'react';
import { useStarredEntity } from '../../hooks/useStarredEntity';
type Props = ComponentProps<typeof IconButton> & { entity: Entity };
@@ -40,16 +40,17 @@ export const favoriteEntityIcon = (isStarred: boolean) =>
* @param props MaterialUI IconButton props extended by required `entity` prop
*/
export const FavoriteEntity = (props: Props) => {
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const isStarred = isStarredEntity(props.entity);
const { toggleStarredEntity, isStarredEntity } = useStarredEntity(
props.entity,
);
return (
<IconButton
color="inherit"
{...props}
onClick={() => toggleStarredEntity(props.entity)}
onClick={() => toggleStarredEntity()}
>
<Tooltip title={favoriteEntityTooltip(isStarred)}>
{favoriteEntityIcon(isStarred)}
<Tooltip title={favoriteEntityTooltip(isStarredEntity)}>
{favoriteEntityIcon(isStarredEntity)}
</Tooltip>
</IconButton>
);
+1
View File
@@ -36,4 +36,5 @@ export { useEntityKinds } from './useEntityKinds';
export { useOwnUser } from './useOwnUser';
export { useRelatedEntities } from './useRelatedEntities';
export { useStarredEntities } from './useStarredEntities';
export { useStarredEntity } from './useStarredEntity';
export { useEntityOwnership } from './useEntityOwnership';
@@ -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 store = mockStorage?.forBucket('starredEntities');
await store?.set('entityRefs', 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();
});
@@ -14,62 +14,49 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { storageApiRef, useApi } from '@backstage/core-plugin-api';
import { useCallback, useEffect, useState } from 'react';
import {
Entity,
EntityName,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { useCallback } from 'react';
import { useObservable } from 'react-use';
import { starredEntitiesApiRef } from '../apis';
const buildEntityKey = (component: Entity) =>
`entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${
component.metadata.name
}`;
function getEntityRef(entityOrRef: Entity | EntityName | string): string {
return typeof entityOrRef === 'string'
? entityOrRef
: stringifyEntityRef(entityOrRef);
}
export const useStarredEntities = () => {
const storageApi = useApi(storageApiRef);
const settingsStore = storageApi.forBucket('settings');
const rawStarredEntityKeys =
settingsStore.get<string[]>('starredEntities') ?? [];
export function useStarredEntities(): {
starredEntities: Set<string>;
toggleStarredEntity: (entityOrRef: Entity | EntityName | string) => void;
isStarredEntity: (entityOrRef: Entity | EntityName | string) => boolean;
} {
const starredEntitiesApi = useApi(starredEntitiesApiRef);
const [starredEntities, setStarredEntities] = useState(
new Set(rawStarredEntityKeys),
);
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));
},
[starredEntities, settingsStore],
const starredEntities = useObservable(
starredEntitiesApi.starredEntitie$(),
new Set<string>(),
);
const isStarredEntity = useCallback(
(entity: Entity) => {
const entityKey = buildEntityKey(entity);
return starredEntities.has(entityKey);
},
(entityOrRef: Entity | EntityName | string) =>
starredEntities.has(getEntityRef(entityOrRef)),
[starredEntities],
);
const toggleStarredEntity = useCallback(
(entityOrRef: Entity | EntityName | string) =>
starredEntitiesApi.toggleStarred(getEntityRef(entityOrRef)).then(),
[starredEntitiesApi],
);
return {
starredEntities,
toggleStarredEntity,
isStarredEntity,
};
};
}
@@ -0,0 +1,103 @@
/*
* 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, EntityName } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { renderHook } from '@testing-library/react-hooks';
import React, { PropsWithChildren } from 'react';
import Observable from 'zen-observable';
import { StarredEntitiesApi, starredEntitiesApiRef } from '../apis';
import { useStarredEntity } from './useStarredEntity';
describe('useStarredEntity', () => {
const mockStarredEntitiesApi: jest.Mocked<StarredEntitiesApi> = {
toggleStarred: jest.fn(),
starredEntitie$: jest.fn(),
};
let wrapper: React.ComponentType;
beforeEach(() => {
wrapper = ({ children }: PropsWithChildren<{}>) => (
<ApiProvider
apis={ApiRegistry.with(starredEntitiesApiRef, mockStarredEntitiesApi)}
>
{children}
</ApiProvider>
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe.each`
title | entityOrRef
${'entity reference'} | ${'component:default/mock'}
${'entity name'} | ${{ kind: 'component', namespace: 'default', name: 'mock' } as EntityName}
${'entity'} | ${{ apiVersion: '1', kind: 'Component', metadata: { name: 'mock' } } as Entity}
`('with $title', ({ entityOrRef }) => {
describe('toggleStarredEntity', () => {
it('should toggle starred entity', () => {
mockStarredEntitiesApi.starredEntitie$.mockReturnValue(Observable.of());
mockStarredEntitiesApi.toggleStarred.mockResolvedValue();
const { result } = renderHook(() => useStarredEntity(entityOrRef), {
wrapper,
});
result.current.toggleStarredEntity();
expect(mockStarredEntitiesApi.toggleStarred).toBeCalledTimes(1);
expect(mockStarredEntitiesApi.toggleStarred).toBeCalledWith(
'component:default/mock',
);
});
});
describe('isStarredEntity', () => {
it('should return not starred entity', () => {
mockStarredEntitiesApi.starredEntitie$.mockReturnValue(Observable.of());
mockStarredEntitiesApi.toggleStarred.mockResolvedValue();
const { result } = renderHook(() => useStarredEntity(entityOrRef), {
wrapper,
});
expect(result.current.isStarredEntity).toBe(false);
});
it('should return starred entity', async () => {
mockStarredEntitiesApi.starredEntitie$.mockReturnValue(
Observable.of(new Set(['component:default/mock'])),
);
mockStarredEntitiesApi.toggleStarred.mockResolvedValue();
const { result, waitForNextUpdate } = renderHook(
() => useStarredEntity(entityOrRef),
{
wrapper,
},
);
// the initial value will always be false because the observable triggers async
expect(result.current.isStarredEntity).toBe(false);
await waitForNextUpdate();
expect(result.current.isStarredEntity).toBe(true);
});
});
});
});
@@ -0,0 +1,61 @@
/*
* 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,
EntityName,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { useCallback, useEffect, useState } from 'react';
import { starredEntitiesApiRef } from '../apis';
function getEntityRef(entityOrRef: Entity | EntityName | string): string {
return typeof entityOrRef === 'string'
? entityOrRef
: stringifyEntityRef(entityOrRef);
}
export function useStarredEntity(entityOrRef: Entity | EntityName | string): {
toggleStarredEntity: () => void;
isStarredEntity: boolean;
} {
const starredEntitiesApi = useApi(starredEntitiesApiRef);
const [isStarredEntity, setIsStarredEntity] = useState(false);
useEffect(() => {
const subscription = starredEntitiesApi.starredEntitie$().subscribe({
next(starredEntities: Set<string>) {
setIsStarredEntity(starredEntities.has(getEntityRef(entityOrRef)));
},
});
return () => {
subscription.unsubscribe();
};
}, [entityOrRef, starredEntitiesApi]);
const toggleStarredEntity = useCallback(
() => starredEntitiesApi.toggleStarred(getEntityRef(entityOrRef)).then(),
[entityOrRef, starredEntitiesApi],
);
return {
toggleStarredEntity,
isStarredEntity,
};
}
+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,12 @@
* limitations under the License.
*/
import React, { ComponentProps, useMemo } from 'react';
import { useStarredEntities } from '@backstage/plugin-catalog-react';
import { IconButton, makeStyles, Tooltip, withStyles } from '@material-ui/core';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { Entity } from '@backstage/catalog-model';
import { useStarredEntity } from '@backstage/plugin-catalog-react';
import { IconButton, makeStyles, Tooltip, withStyles } from '@material-ui/core';
import Star from '@material-ui/icons/Star';
import StarBorder from '@material-ui/icons/StarBorder';
import React, { ComponentProps } from 'react';
type Props = ComponentProps<typeof IconButton> & { entity: Entity };
@@ -56,20 +56,18 @@ export const favouriteTemplateIcon = (isStarred: boolean) =>
*/
export const FavouriteTemplate = (props: Props) => {
const classes = useStyles();
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const isStarred = useMemo(
() => isStarredEntity(props.entity),
[isStarredEntity, props.entity],
const { toggleStarredEntity, isStarredEntity } = useStarredEntity(
props.entity,
);
return (
<IconButton
color="inherit"
className={classes.starButton}
{...props}
onClick={() => toggleStarredEntity(props.entity)}
onClick={() => toggleStarredEntity()}
>
<Tooltip title={favouriteTemplateTooltip(isStarred)}>
{favouriteTemplateIcon(isStarred)}
<Tooltip title={favouriteTemplateTooltip(isStarredEntity)}>
{favouriteTemplateIcon(isStarredEntity)}
</Tooltip>
</IconButton>
);
@@ -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()],
[storageApiRef, storageApi],
[starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi })],
]);
it('should render a TechDocs home page', async () => {