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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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],
|
||||
])}
|
||||
>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user