Enable Filtering of the Catalog Page (#1143)
* feat(catalog/Filters): Add ability to render a React component as the count. Good for loading states and async data rendering for counts. * chore(Catalog/filters): Updating the table so that we use loading states of the table rather than the panel * chore(Catalog/filters): added some nice things for enabling the filtering with some nice count componeents * feat(Catalog/filters): Fetch the correct data and added in enum types to make some nice resolvers * chore(Catalog/filters): Use the new enum type here * chore(Catalog/filters): Removing the unused import to fix lintig * chore(Catalog/filters): Addressing some PR comments * feat(catalog/filters): Making WebStorage return the same instance for the same bucket for subscriptions * chore(core/Storage): fixing some issues with different instances of the storage and adding tests for it * chore(catalog/filters): fixing some tests and trying to remove some of the act warnings in the tests
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { WebStorage } from './WebStorage';
|
||||
import { CreateStorageApiOptions, StorageApi } from '../../definitions';
|
||||
|
||||
describe('WebStorage Storage API', () => {
|
||||
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
|
||||
const createWebStorage = (
|
||||
@@ -161,4 +162,12 @@ describe('WebStorage Storage API', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a singleton for the same namespace and same bucket', async () => {
|
||||
const rootStorage = createWebStorage({
|
||||
namespace: '/Test/Mock/Thing/Thing ',
|
||||
});
|
||||
|
||||
expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { Observable } from '../../../types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
const buckets = new Map<string, WebStorage>();
|
||||
|
||||
export class WebStorage implements StorageApi {
|
||||
constructor(
|
||||
private readonly namespace: string,
|
||||
@@ -46,7 +48,11 @@ export class WebStorage implements StorageApi {
|
||||
}
|
||||
|
||||
forBucket(name: string): WebStorage {
|
||||
return new WebStorage(`${this.namespace}/${name}`, this.errorApi);
|
||||
const bucketPath = `${this.namespace}/${name}`;
|
||||
if (!buckets.has(bucketPath)) {
|
||||
buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi));
|
||||
}
|
||||
return buckets.get(bucketPath)!;
|
||||
}
|
||||
|
||||
async set<T>(key: string, data: T): Promise<void> {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
storageApiRef,
|
||||
StorageApi,
|
||||
WebStorage,
|
||||
} from '@backstage/core';
|
||||
} from '@backstage/core-api';
|
||||
|
||||
export default {
|
||||
title: 'DismissableBanner',
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
CreateStorageApiOptions,
|
||||
StorageApi,
|
||||
WebStorage,
|
||||
} from '@backstage/core';
|
||||
} from '@backstage/core-api';
|
||||
|
||||
describe('<DismissableBanner />', () => {
|
||||
let apis: ApiRegistry;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React, { FC, ReactNode, useState, useEffect } from 'react';
|
||||
import { useApi, storageApiRef } from '@backstage/core';
|
||||
import { useApi, storageApiRef } from '@backstage/core-api';
|
||||
import { useObservable } from 'react-use';
|
||||
import classNames from 'classnames';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
|
||||
@@ -42,11 +42,13 @@
|
||||
"@backstage/test-utils": "^0.1.1-alpha.6",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/react-hooks": "^3.3.0",
|
||||
"@testing-library/user-event": "^10.2.4",
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/testing-library__jest-dom": "^5.0.4",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"react-test-renderer": "^16.13.1"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.{js,d.ts}"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { catalogApiRef } from '../../api/types';
|
||||
import { useAsync } from 'react-use';
|
||||
import { CircularProgress, useTheme } from '@material-ui/core';
|
||||
|
||||
export const AllServicesCount: React.FC<{}> = () => {
|
||||
const theme = useTheme();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value, loading } = useAsync(() => catalogApi.getEntities());
|
||||
|
||||
if (loading) {
|
||||
return <CircularProgress size={theme.spacing(2)} />;
|
||||
}
|
||||
|
||||
return <span>{value?.length ?? '-'}</span>;
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
|
||||
import { FilterGroupItem } from '../../types';
|
||||
|
||||
describe('Catalog Filter', () => {
|
||||
it('should render the different groups', async () => {
|
||||
@@ -40,11 +41,11 @@ describe('Catalog Filter', () => {
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
id: FilterGroupItem.ALL,
|
||||
label: 'First Label',
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
id: FilterGroupItem.STARRED,
|
||||
label: 'Second Label',
|
||||
},
|
||||
],
|
||||
@@ -67,12 +68,12 @@ describe('Catalog Filter', () => {
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
id: FilterGroupItem.ALL,
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
id: FilterGroupItem.STARRED,
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
@@ -96,12 +97,12 @@ describe('Catalog Filter', () => {
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
id: FilterGroupItem.ALL,
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
id: FilterGroupItem.STARRED,
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
@@ -128,4 +129,29 @@ describe('Catalog Filter', () => {
|
||||
|
||||
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item);
|
||||
});
|
||||
|
||||
it('should render a component when a function is passed to the count component', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: FilterGroupItem.ALL,
|
||||
label: 'First Label',
|
||||
count: () => <b>BACKSTAGE!</b>,
|
||||
},
|
||||
{
|
||||
id: FilterGroupItem.STARRED,
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
expect(await findByText('BACKSTAGE!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,13 +25,12 @@ import {
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import type { IconComponent } from '@backstage/core';
|
||||
|
||||
import { FilterGroupItem } from '../../types';
|
||||
export type CatalogFilterItem = {
|
||||
id: string;
|
||||
id: FilterGroupItem;
|
||||
label: string;
|
||||
icon?: IconComponent;
|
||||
count?: number;
|
||||
loading?: boolean;
|
||||
count?: number | React.FC;
|
||||
};
|
||||
|
||||
export type CatalogFilterGroup = {
|
||||
@@ -105,7 +104,11 @@ export const CatalogFilter: React.FC<CatalogFilterProps> = ({
|
||||
{item.label}
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
{item.count}
|
||||
{typeof item.count === 'function' ? (
|
||||
<item.count />
|
||||
) : (
|
||||
item.count
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { StarredCount } from './StarredCount';
|
||||
import * as Hooks from '../../hooks/useStarredEntites';
|
||||
|
||||
describe('Starred Count', () => {
|
||||
it('should render the count returned from the hook', async () => {
|
||||
jest.spyOn(Hooks, 'useStarredEntities').mockReturnValue({
|
||||
starredEntities: new Set(['id1', 'id2', 'id3', 'id4']),
|
||||
});
|
||||
|
||||
const { findByText } = render(wrapInTestApp(<StarredCount />));
|
||||
|
||||
expect(await findByText('4')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
|
||||
export const StarredCount: React.FC<{}> = () => {
|
||||
const { starredEntities } = useStarredEntities();
|
||||
return <span>{starredEntities.size}</span>;
|
||||
};
|
||||
@@ -18,47 +18,34 @@ import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import CatalogPage from './CatalogPage';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
CreateStorageApiOptions,
|
||||
ApiProvider,
|
||||
errorApiRef,
|
||||
storageApiRef,
|
||||
StorageApi,
|
||||
WebStorage,
|
||||
} from '@backstage/core';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { wrapInTestApp, MockErrorApi } from '@backstage/test-utils';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { CatalogApi } from '../../api/types';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
const errorApi = { post: () => {} };
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
kind: 'Component',
|
||||
},
|
||||
] as Entity[]),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
};
|
||||
|
||||
const mockWebStorageErrorApi = { post: jest.fn(), error$: jest.fn() };
|
||||
const createWebStorage = (
|
||||
args?: Partial<CreateStorageApiOptions>,
|
||||
): StorageApi => {
|
||||
return WebStorage.create({
|
||||
errorApi: mockWebStorageErrorApi,
|
||||
...args,
|
||||
});
|
||||
};
|
||||
const storageApi = createWebStorage();
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
const mockErrorApi = new MockErrorApi();
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
kind: 'Component',
|
||||
},
|
||||
] as Entity[]),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
};
|
||||
|
||||
// this test right now causes some red lines in the log output when running tests
|
||||
// related to some theme issues in mui-table
|
||||
// https://github.com/mbrn/material-table/issues/1293
|
||||
@@ -67,9 +54,9 @@ describe('CatalogPage', () => {
|
||||
wrapInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, errorApi],
|
||||
[errorApiRef, mockErrorApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
[storageApiRef, storageApi],
|
||||
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
|
||||
])}
|
||||
>
|
||||
<CatalogPage />
|
||||
|
||||
@@ -27,19 +27,22 @@ import {
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
|
||||
import { Button, Link, makeStyles, Typography } from '@material-ui/core';
|
||||
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useAsync } from 'react-use';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { Component } from '../../data/component';
|
||||
import { defaultFilter, filterGroups } from '../../data/filters';
|
||||
import { defaultFilter, filterGroups, dataResolvers } from '../../data/filters';
|
||||
import { entityToComponent, findLocationForEntityMeta } from '../../data/utils';
|
||||
import {
|
||||
CatalogFilter,
|
||||
CatalogFilterItem,
|
||||
} from '../CatalogFilter/CatalogFilter';
|
||||
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
|
||||
import CatalogTable from '../CatalogTable/CatalogTable';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
@@ -57,15 +60,20 @@ const useStyles = makeStyles(theme => ({
|
||||
|
||||
const CatalogPage: FC<{}> = () => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value, error, loading } = useAsync(() => catalogApi.getEntities());
|
||||
const { starredEntities } = useStarredEntities();
|
||||
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
|
||||
defaultFilter,
|
||||
);
|
||||
const { value, error, loading } = useAsync(
|
||||
() => dataResolvers[selectedFilter.id]({ catalogApi, starredEntities }),
|
||||
[selectedFilter.id],
|
||||
);
|
||||
|
||||
const onFilterSelected = useCallback(
|
||||
selected => setSelectedFilter(selected),
|
||||
[],
|
||||
);
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const actions = [
|
||||
|
||||
@@ -41,16 +41,6 @@ const components: Component[] = [
|
||||
];
|
||||
|
||||
describe('CatalogTable component', () => {
|
||||
it('should render loading when loading prop it set to true', async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<CatalogTable titlePreamble="Owned" components={[]} loading />,
|
||||
),
|
||||
);
|
||||
const progress = await rendered.findByTestId('progress');
|
||||
expect(progress).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error message when error is passed in props', async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Progress, Table, TableColumn } from '@backstage/core';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { Link } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { FC } from 'react';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
import { Link as RouterLink, generatePath } from 'react-router-dom';
|
||||
import { Component } from '../../data/component';
|
||||
|
||||
import { entityRoute } from '../../routes';
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
@@ -60,9 +61,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
|
||||
titlePreamble,
|
||||
actions,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<Alert severity="error">
|
||||
@@ -74,8 +73,14 @@ const CatalogTable: FC<CatalogTableProps> = ({
|
||||
|
||||
return (
|
||||
<Table
|
||||
isLoading={loading}
|
||||
columns={columns}
|
||||
options={{ paging: false, actionsColumnIndex: -1 }}
|
||||
options={{
|
||||
paging: false,
|
||||
actionsColumnIndex: -1,
|
||||
loadingType: 'linear',
|
||||
showEmptyDataSourceMessage: !loading,
|
||||
}}
|
||||
title={`${titlePreamble} (${(components && components.length) || 0})`}
|
||||
data={components}
|
||||
actions={actions}
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('ComponentContextMenu', () => {
|
||||
it('should call onUnregisterComponent on button click', async () => {
|
||||
await act(async () => {
|
||||
const mockCallback = jest.fn();
|
||||
const menu = await render(
|
||||
const menu = render(
|
||||
<ComponentContextMenu onUnregisterComponent={mockCallback} />,
|
||||
);
|
||||
const button = await menu.findByTestId('menu-button');
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import ComponentPage from './ComponentPage';
|
||||
import { render } from '@testing-library/react';
|
||||
import { render, wait } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
|
||||
@@ -38,7 +38,7 @@ const errorApi = { post: () => {} };
|
||||
describe('ComponentPage', () => {
|
||||
it('should redirect to component table page when name is not provided', async () => {
|
||||
const props = getTestProps('');
|
||||
await render(
|
||||
render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
@@ -55,6 +55,9 @@ describe('ComponentPage', () => {
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
expect(props.history.push).toHaveBeenCalledWith('/catalog');
|
||||
|
||||
await wait(() =>
|
||||
expect(props.history.push).toHaveBeenCalledWith('/catalog'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,21 +19,26 @@ import {
|
||||
} from '../components/CatalogFilter/CatalogFilter';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import StarIcon from '@material-ui/icons/Star';
|
||||
import { StarredCount } from '../components/CatalogFilter/StarredCount';
|
||||
import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount';
|
||||
import { FilterGroupItem } from '../types';
|
||||
import { CatalogApi } from '../..';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export const filterGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Personal',
|
||||
items: [
|
||||
{
|
||||
id: 'owned',
|
||||
id: FilterGroupItem.OWNED,
|
||||
label: 'Owned',
|
||||
count: 123,
|
||||
count: 0,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
id: 'starred',
|
||||
id: FilterGroupItem.STARRED,
|
||||
label: 'Starred',
|
||||
count: 10,
|
||||
count: StarredCount,
|
||||
icon: StarIcon,
|
||||
},
|
||||
],
|
||||
@@ -43,12 +48,34 @@ export const filterGroups: CatalogFilterGroup[] = [
|
||||
name: 'Company',
|
||||
items: [
|
||||
{
|
||||
id: 'all',
|
||||
id: FilterGroupItem.ALL,
|
||||
label: 'All Services',
|
||||
count: 123,
|
||||
count: AllServicesCount,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
type ResolverFunction = ({
|
||||
catalogApi,
|
||||
starredEntities,
|
||||
}: {
|
||||
catalogApi: CatalogApi;
|
||||
starredEntities: Set<string>;
|
||||
}) => Promise<Entity[]>;
|
||||
|
||||
export const dataResolvers: Record<FilterGroupItem, ResolverFunction> = {
|
||||
[FilterGroupItem.OWNED]: async () => [],
|
||||
[FilterGroupItem.ALL]: async ({ catalogApi }) => {
|
||||
return catalogApi.getEntities();
|
||||
},
|
||||
[FilterGroupItem.STARRED]: async ({ catalogApi, starredEntities }) => {
|
||||
const allEntities = await catalogApi.getEntities();
|
||||
|
||||
return allEntities.filter(entity =>
|
||||
starredEntities.has(entity.metadata.name),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { useState, useEffect } from 'react';
|
||||
import { useApi, storageApiRef } from '@backstage/core';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
export const useStarredEntities = () => {
|
||||
const storageApi = useApi(storageApiRef);
|
||||
const settingsStore = storageApi.forBucket('settings');
|
||||
const rawStarredItems = settingsStore.get<string[]>('starredEntities') ?? [];
|
||||
|
||||
const [starredEntities, setStarredEntities] = useState(
|
||||
new Set(rawStarredItems),
|
||||
);
|
||||
|
||||
const observedItems = useObservable(
|
||||
settingsStore.observe$<string[]>('starredEntities'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (observedItems?.newValue) {
|
||||
const currentValue = observedItems?.newValue ?? [];
|
||||
setStarredEntities(new Set(currentValue));
|
||||
}
|
||||
}, [observedItems?.newValue]);
|
||||
|
||||
return {
|
||||
starredEntities,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useStarredEntities } from './useStarredEntites';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
storageApiRef,
|
||||
WebStorage,
|
||||
StorageApi,
|
||||
} from '@backstage/core';
|
||||
import { MockErrorApi } from '@backstage/test-utils';
|
||||
|
||||
describe('useStarredEntities', () => {
|
||||
let mockStorage: StorageApi | undefined;
|
||||
|
||||
const wrapper: React.FC<{}> = ({ children }) => {
|
||||
return (
|
||||
<ApiProvider apis={ApiRegistry.with(storageApiRef, 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 });
|
||||
|
||||
expect(result.current.starredEntities.size).toBe(0);
|
||||
});
|
||||
it('should return a set with the current items when there is items in storage', async () => {
|
||||
const expectedIds = ['i', 'am', 'some', 'test', 'ids'];
|
||||
const store = mockStorage?.forBucket('settings');
|
||||
await store?.set('starredEntities', expectedIds);
|
||||
|
||||
const { result } = renderHook(() => useStarredEntities(), { wrapper });
|
||||
|
||||
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 store = mockStorage?.forBucket('settings');
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => useStarredEntities(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.starredEntities.size).toBe(0);
|
||||
expect(result.current.starredEntities.has('something')).toBeFalsy();
|
||||
|
||||
// Make this happen after awaiting for the next update so we can
|
||||
// catch when the hook re-renders with the latest data
|
||||
setTimeout(() => store?.set('starredEntities', ['something']), 1);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.starredEntities.size).toBe(1);
|
||||
expect(result.current.starredEntities.has('something')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -157,3 +157,9 @@ export type KindParser = {
|
||||
envelope: DescriptorEnvelope,
|
||||
): Promise<DescriptorEnvelope | undefined>;
|
||||
};
|
||||
|
||||
export enum FilterGroupItem {
|
||||
ALL = 'ALL',
|
||||
STARRED = 'STARRED',
|
||||
OWNED = 'OWNED',
|
||||
}
|
||||
|
||||
@@ -891,6 +891,13 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/runtime@^7.5.4":
|
||||
version "7.10.2"
|
||||
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.2.tgz#d103f21f2602497d38348a32e008637d506db839"
|
||||
integrity sha512-6sF3uQw2ivImfVIl62RZ7MXhO2tap69WeWK57vAaimT6AZbE4FbqjdEJIN1UqoD6wI6B+1n9UiagafH1sxjOtg==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/template@^7.3.3", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6":
|
||||
version "7.8.6"
|
||||
resolved "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b"
|
||||
@@ -3279,6 +3286,14 @@
|
||||
lodash "^4.17.15"
|
||||
redent "^3.0.0"
|
||||
|
||||
"@testing-library/react-hooks@^3.3.0":
|
||||
version "3.3.0"
|
||||
resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.3.0.tgz#dc217bfce8e7c34a99c811d73d23feef957b7c1d"
|
||||
integrity sha512-rE9geI1+HJ6jqXkzzJ6abREbeud6bLF8OmF+Vyc7gBoPwZAEVBYjbC1up5nNoVfYBhO5HUwdD4u9mTehAUeiyw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.5.4"
|
||||
"@types/testing-library__react-hooks" "^3.0.0"
|
||||
|
||||
"@testing-library/react@^9.3.2":
|
||||
version "9.5.0"
|
||||
resolved "https://registry.npmjs.org/@testing-library/react/-/react-9.5.0.tgz#71531655a7890b61e77a1b39452fbedf0472ca5e"
|
||||
@@ -3910,6 +3925,13 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-test-renderer@*":
|
||||
version "16.9.2"
|
||||
resolved "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.9.2.tgz#e1c408831e8183e5ad748fdece02214a7c2ab6c5"
|
||||
integrity sha512-4eJr1JFLIAlWhzDkBCkhrOIWOvOxcCAfQh+jiKg7l/nNZcCIL2MHl2dZhogIFKyHzedVWHaVP1Yydq/Ruu4agw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-textarea-autosize@^4.3.3":
|
||||
version "4.3.5"
|
||||
resolved "https://registry.npmjs.org/@types/react-textarea-autosize/-/react-textarea-autosize-4.3.5.tgz#6c4d2753fa1864c98c0b2b517f67bb1f6e4c46de"
|
||||
@@ -4067,6 +4089,14 @@
|
||||
dependencies:
|
||||
"@types/jest" "*"
|
||||
|
||||
"@types/testing-library__react-hooks@^3.0.0":
|
||||
version "3.2.0"
|
||||
resolved "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.2.0.tgz#52f3a109bef06080e3b1e3ae7ea1c014ce859897"
|
||||
integrity sha512-dE8iMTuR5lzB+MqnxlzORlXzXyCL0EKfzH0w/lau20OpkHD37EaWjZDz0iNG8b71iEtxT4XKGmSKAGVEqk46mw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
"@types/react-test-renderer" "*"
|
||||
|
||||
"@types/testing-library__react@^9.1.2":
|
||||
version "9.1.3"
|
||||
resolved "https://registry.npmjs.org/@types/testing-library__react/-/testing-library__react-9.1.3.tgz#35eca61cc6ea923543796f16034882a1603d7302"
|
||||
@@ -15617,6 +15647,16 @@ react-syntax-highlighter@^12.2.1:
|
||||
prismjs "^1.8.4"
|
||||
refractor "^2.4.1"
|
||||
|
||||
react-test-renderer@^16.13.1:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1"
|
||||
integrity sha512-Sn2VRyOK2YJJldOqoh8Tn/lWQ+ZiKhyZTPtaO0Q6yNj+QDbmRkVFap6pZPy3YQk8DScRDfyqm/KxKYP9gCMRiQ==
|
||||
dependencies:
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.2"
|
||||
react-is "^16.8.6"
|
||||
scheduler "^0.19.1"
|
||||
|
||||
react-textarea-autosize@^7.1.0:
|
||||
version "7.1.2"
|
||||
resolved "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-7.1.2.tgz#70fdb333ef86bcca72717e25e623e90c336e2cda"
|
||||
|
||||
Reference in New Issue
Block a user