Merge branch 'master' of github.com:spotify/backstage into shmidt-i/scaffolder-flow-frontend
This commit is contained in:
@@ -40,7 +40,7 @@
|
||||
"classnames": "^2.2.6",
|
||||
"clsx": "^1.1.0",
|
||||
"lodash": "^4.17.15",
|
||||
"material-table": "^1.58.0",
|
||||
"material-table": "1.62.x",
|
||||
"prop-types": "^15.7.2",
|
||||
"rc-progress": "^3.0.0",
|
||||
"react": "^16.12.0",
|
||||
|
||||
@@ -14,18 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, forwardRef } from 'react';
|
||||
import MTable, {
|
||||
MTableCell,
|
||||
MTableHeader,
|
||||
MTableToolbar,
|
||||
MaterialTableProps,
|
||||
Options,
|
||||
Column,
|
||||
} from 'material-table';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { makeStyles, useTheme, Typography } from '@material-ui/core';
|
||||
|
||||
import { makeStyles, Typography, useTheme } from '@material-ui/core';
|
||||
// Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51
|
||||
import AddBox from '@material-ui/icons/AddBox';
|
||||
import ArrowUpward from '@material-ui/icons/ArrowUpward';
|
||||
@@ -42,6 +32,15 @@ import Remove from '@material-ui/icons/Remove';
|
||||
import SaveAlt from '@material-ui/icons/SaveAlt';
|
||||
import Search from '@material-ui/icons/Search';
|
||||
import ViewColumn from '@material-ui/icons/ViewColumn';
|
||||
import MTable, {
|
||||
Column,
|
||||
MaterialTableProps,
|
||||
MTableCell,
|
||||
MTableHeader,
|
||||
MTableToolbar,
|
||||
Options,
|
||||
} from 'material-table';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
const tableIcons = {
|
||||
Add: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
@@ -131,10 +130,10 @@ const useToolbarStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const convertColumns = (
|
||||
columns: TableColumn[],
|
||||
function convertColumns<T extends object>(
|
||||
columns: TableColumn<T>[],
|
||||
theme: BackstageTheme,
|
||||
): TableColumn[] => {
|
||||
): TableColumn<T>[] {
|
||||
return columns.map(column => {
|
||||
const headerStyle: React.CSSProperties = {};
|
||||
const cellStyle: React.CSSProperties = {};
|
||||
@@ -150,25 +149,26 @@ const convertColumns = (
|
||||
cellStyle,
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export interface TableColumn extends Column<{}> {
|
||||
export interface TableColumn<T extends object = {}> extends Column<T> {
|
||||
highlight?: boolean;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface TableProps extends MaterialTableProps<{}> {
|
||||
columns: TableColumn[];
|
||||
export interface TableProps<T extends object = {}>
|
||||
extends MaterialTableProps<T> {
|
||||
columns: TableColumn<T>[];
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export const Table: FC<TableProps> = ({
|
||||
export function Table<T extends object = {}>({
|
||||
columns,
|
||||
options,
|
||||
title,
|
||||
subtitle,
|
||||
...props
|
||||
}) => {
|
||||
}: TableProps<T>) {
|
||||
const cellClasses = useCellStyles();
|
||||
const headerClasses = useHeaderStyles();
|
||||
const toolbarClasses = useToolbarStyles();
|
||||
@@ -183,7 +183,7 @@ export const Table: FC<TableProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<MTable
|
||||
<MTable<T>
|
||||
components={{
|
||||
Cell: cellProps => (
|
||||
<MTableCell className={cellClasses.root} {...cellProps} />
|
||||
@@ -211,4 +211,4 @@ export const Table: FC<TableProps> = ({
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
*/
|
||||
|
||||
export { Table } from './Table';
|
||||
export type { TableColumn } from './Table';
|
||||
export type { TableColumn, TableProps } from './Table';
|
||||
export { SubvalueCell } from './SubvalueCell';
|
||||
|
||||
@@ -42,14 +42,15 @@ export type Tab = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const HeaderTabs: React.FC<{
|
||||
tabs: Tab[];
|
||||
onChange?: (index: Number) => void;
|
||||
onChange?: (index: number) => void;
|
||||
}> = ({ tabs, onChange }) => {
|
||||
const [selectedTab, setSelectedTab] = useState<Number>(0);
|
||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
||||
const styles = useStyles();
|
||||
|
||||
const handleChange = (_: React.ChangeEvent<{}>, index: Number) => {
|
||||
const handleChange = (_: React.ChangeEvent<{}>, index: number) => {
|
||||
setSelectedTab(index);
|
||||
if (onChange) onChange(index);
|
||||
};
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-router": "^6.0.0-alpha.5",
|
||||
"react-router-dom": "^6.0.0-alpha.5"
|
||||
"react-router-dom": "^6.0.0-alpha.5",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^25.2.2",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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 { MockStorageApi } from './MockStorageApi';
|
||||
import { StorageApi } from '@backstage/core-api';
|
||||
|
||||
describe('WebStorage Storage API', () => {
|
||||
const createMockStorage = (): StorageApi => {
|
||||
return MockStorageApi.create();
|
||||
};
|
||||
|
||||
it('should return undefined for values which are unset', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
expect(storage.get('myfakekey')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow the setting and getting of the simple data structures', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
await storage.set('myfakekey', 'helloimastring');
|
||||
await storage.set('mysecondfakekey', 1234);
|
||||
await storage.set('mythirdfakekey', true);
|
||||
expect(storage.get('myfakekey')).toBe('helloimastring');
|
||||
expect(storage.get('mysecondfakekey')).toBe(1234);
|
||||
expect(storage.get('mythirdfakekey')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow setting of complex datastructures', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const mockData = {
|
||||
something: 'here',
|
||||
is: [{ super: { complex: [{ but: 'something', why: true }] } }],
|
||||
};
|
||||
|
||||
await storage.set('myfakekey', mockData);
|
||||
|
||||
expect(storage.get('myfakekey')).toEqual(mockData);
|
||||
});
|
||||
|
||||
it('should subscribe to key changes when setting a new value', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const wrongKeyNextHandler = jest.fn();
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
const mockData = { hello: 'im a great new value' };
|
||||
|
||||
await new Promise(resolve => {
|
||||
storage.observe$<String>('correctKey').subscribe({
|
||||
next: (...args) => {
|
||||
selectedKeyNextHandler(...args);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
|
||||
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
|
||||
|
||||
storage.set('correctKey', mockData);
|
||||
});
|
||||
|
||||
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
|
||||
key: 'correctKey',
|
||||
newValue: mockData,
|
||||
});
|
||||
});
|
||||
|
||||
it('should subscribe to key changes when deleting a value', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const wrongKeyNextHandler = jest.fn();
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
const mockData = { hello: 'im a great new value' };
|
||||
|
||||
storage.set('correctKey', mockData);
|
||||
|
||||
await new Promise(resolve => {
|
||||
storage.observe$('correctKey').subscribe({
|
||||
next: (...args) => {
|
||||
selectedKeyNextHandler(...args);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
|
||||
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
|
||||
|
||||
storage.remove('correctKey');
|
||||
});
|
||||
|
||||
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
|
||||
key: 'correctKey',
|
||||
newValue: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to create different buckets for different uses', async () => {
|
||||
const rootStorage = createMockStorage();
|
||||
|
||||
const firstStorage = rootStorage.forBucket('userSettings');
|
||||
const secondStorage = rootStorage.forBucket('profileSettings');
|
||||
const keyName = 'blobby';
|
||||
|
||||
await firstStorage.set(keyName, 'boop');
|
||||
await secondStorage.set(keyName, 'deerp');
|
||||
|
||||
expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName));
|
||||
expect(firstStorage.get(keyName)).toBe('boop');
|
||||
expect(secondStorage.get(keyName)).toBe('deerp');
|
||||
});
|
||||
|
||||
it('should not clash with other namesapces when creating buckets', async () => {
|
||||
const rootStorage = createMockStorage();
|
||||
|
||||
// when getting key test2 it will translate to /profile/something/deep/test2
|
||||
const firstStorage = rootStorage
|
||||
.forBucket('profile')
|
||||
.forBucket('something')
|
||||
.forBucket('deep');
|
||||
// when getting key deep/test2 it will translate to /profile/something/deep/test2
|
||||
const secondStorage = rootStorage.forBucket('profile/something');
|
||||
|
||||
await firstStorage.set('test2', { error: true });
|
||||
|
||||
expect(secondStorage.get('deep/test2')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 {
|
||||
Observable,
|
||||
StorageApi,
|
||||
storageApiRef,
|
||||
StorageValueChange,
|
||||
} from '@backstage/core-api';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
export type MockStorageBucket = { [key: string]: any };
|
||||
|
||||
export class MockStorageApi implements StorageApi {
|
||||
static factory = {
|
||||
implements: storageApiRef,
|
||||
deps: {},
|
||||
factory: () => MockStorageApi.create(),
|
||||
};
|
||||
|
||||
private readonly namespace: string;
|
||||
private readonly data: MockStorageBucket;
|
||||
|
||||
private constructor(namespace: string, data?: MockStorageBucket) {
|
||||
this.namespace = namespace;
|
||||
this.data = { ...data };
|
||||
}
|
||||
|
||||
static create(data?: MockStorageBucket) {
|
||||
return new MockStorageApi('', data);
|
||||
}
|
||||
|
||||
forBucket(name: string): StorageApi {
|
||||
return new MockStorageApi(`${this.namespace}/${name}`, this.data);
|
||||
}
|
||||
|
||||
get<T>(key: string): T | undefined {
|
||||
return this.data[this.getKeyName(key)];
|
||||
}
|
||||
|
||||
async set<T>(key: string, data: T): Promise<void> {
|
||||
this.data[this.getKeyName(key)] = data;
|
||||
this.notifyChanges({ key, newValue: data });
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
delete this.data[this.getKeyName(key)];
|
||||
this.notifyChanges({ key, newValue: undefined });
|
||||
}
|
||||
|
||||
observe$<T>(key: string): Observable<StorageValueChange<T>> {
|
||||
return this.observable.filter(({ key: messageKey }) => messageKey === key);
|
||||
}
|
||||
|
||||
private getKeyName(key: string) {
|
||||
return `${this.namespace}/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
private notifyChanges<T>(message: StorageValueChange<T>) {
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription.next(message);
|
||||
}
|
||||
}
|
||||
|
||||
private subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<StorageValueChange>
|
||||
>();
|
||||
|
||||
private readonly observable = new ObservableImpl<StorageValueChange>(
|
||||
subscriber => {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { MockStorageApi } from './MockStorageApi';
|
||||
export type { MockStorageBucket } from './MockStorageApi';
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export * from './ErrorApi';
|
||||
export * from './StorageApi';
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
*/
|
||||
|
||||
import { ApiTestRegistry } from '@backstage/core-api';
|
||||
import { MockErrorApi } from './apis';
|
||||
import { MockErrorApi, MockStorageApi } from './apis';
|
||||
|
||||
export function createMockApiRegistry(): ApiTestRegistry {
|
||||
const registry = new ApiTestRegistry();
|
||||
|
||||
registry.register(MockErrorApi.factory);
|
||||
registry.register(MockStorageApi.factory);
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -14,195 +14,248 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core';
|
||||
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
|
||||
import { EntityGroup } from '../../data/filters';
|
||||
import { CatalogApi, catalogApiRef } from '../../api/types';
|
||||
import { EntityFilterGroupsProvider } from '../../filter';
|
||||
import { ButtonGroup, CatalogFilter } from './CatalogFilter';
|
||||
|
||||
describe('Catalog Filter', () => {
|
||||
const comp1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component-1',
|
||||
},
|
||||
spec: {
|
||||
owner: 'team',
|
||||
},
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
spec: {
|
||||
owner: 'tools@example.com',
|
||||
type: 'service',
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity2',
|
||||
},
|
||||
spec: {
|
||||
owner: 'not-tools@example.com',
|
||||
type: 'service',
|
||||
},
|
||||
},
|
||||
] as Entity[]),
|
||||
};
|
||||
const comp2 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component-2',
|
||||
},
|
||||
spec: {
|
||||
owner: 'team',
|
||||
},
|
||||
|
||||
const indentityApi: Partial<IdentityApi> = {
|
||||
getUserId: () => 'tools@example.com',
|
||||
};
|
||||
const comp3 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component-3',
|
||||
},
|
||||
spec: {
|
||||
owner: '',
|
||||
},
|
||||
};
|
||||
const defaultFilterProps = {
|
||||
selectedFilter: EntityGroup.ALL,
|
||||
onFilterChange: (type: EntityGroup) => type,
|
||||
entitiesByFilter: {
|
||||
[EntityGroup.ALL]: [comp1, comp2, comp3],
|
||||
[EntityGroup.STARRED]: [comp1],
|
||||
[EntityGroup.OWNED]: [comp1],
|
||||
},
|
||||
};
|
||||
it('should render the different groups', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{ name: 'Test Group 1', items: [] },
|
||||
{ name: 'Test Group 2', items: [] },
|
||||
];
|
||||
const { findByText } = render(
|
||||
|
||||
const renderWrapped = (children: React.ReactNode) =>
|
||||
render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[catalogApiRef, catalogApi],
|
||||
[identityApiRef, indentityApi],
|
||||
[storageApiRef, MockStorageApi.create()],
|
||||
])}
|
||||
>
|
||||
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
it('should render the different groups', async () => {
|
||||
const mockGroups: ButtonGroup[] = [
|
||||
{ name: 'Test Group 1', items: [] },
|
||||
{ name: 'Test Group 2', items: [] },
|
||||
];
|
||||
const { findByText } = renderWrapped(
|
||||
<CatalogFilter buttonGroups={mockGroups} initiallySelected="" />,
|
||||
);
|
||||
for (const group of mockGroups) {
|
||||
expect(await findByText(group.name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render the different items and their names', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
const mockGroups: ButtonGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: EntityGroup.ALL,
|
||||
id: 'all',
|
||||
label: 'First Label',
|
||||
filterFn: () => true,
|
||||
},
|
||||
{
|
||||
id: EntityGroup.STARRED,
|
||||
id: 'starred',
|
||||
label: 'Second Label',
|
||||
filterFn: () => false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
|
||||
),
|
||||
const { findByText } = renderWrapped(
|
||||
<CatalogFilter buttonGroups={mockGroups} initiallySelected="all" />,
|
||||
);
|
||||
|
||||
const [group] = mockGroups;
|
||||
for (const item of group.items) {
|
||||
for (const item of mockGroups[0].items) {
|
||||
expect(await findByText(item.label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render the count in each item', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
it('selects the first item if no desired initial one is set', async () => {
|
||||
const mockGroups: ButtonGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: EntityGroup.ALL,
|
||||
id: 'all',
|
||||
label: 'First Label',
|
||||
count: 3,
|
||||
filterFn: () => true,
|
||||
},
|
||||
{
|
||||
id: EntityGroup.STARRED,
|
||||
id: 'starred',
|
||||
label: 'Second Label',
|
||||
count: 1,
|
||||
filterFn: () => false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { getAllByText } = render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
|
||||
),
|
||||
const onChange = jest.fn();
|
||||
|
||||
renderWrapped(
|
||||
<CatalogFilter
|
||||
buttonGroups={mockGroups}
|
||||
initiallySelected="all"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const key of Object.keys(defaultFilterProps.entitiesByFilter)) {
|
||||
const matcher = new RegExp(
|
||||
`(${defaultFilterProps.entitiesByFilter[key as EntityGroup].length})`,
|
||||
);
|
||||
const items = await getAllByText(matcher);
|
||||
items.forEach(el => expect(el).toBeInTheDocument());
|
||||
}
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
id: 'all',
|
||||
label: 'First Label',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should fire the callback when an item is clicked', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
it('selects the initial item', async () => {
|
||||
const mockGroups: ButtonGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: EntityGroup.ALL,
|
||||
id: 'all',
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
filterFn: () => true,
|
||||
},
|
||||
{
|
||||
id: EntityGroup.STARRED,
|
||||
id: 'starred',
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
filterFn: () => false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const onSelectedChangeHandler = jest.fn();
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter
|
||||
{...defaultFilterProps}
|
||||
groups={mockGroups}
|
||||
onFilterChange={onSelectedChangeHandler}
|
||||
/>,
|
||||
),
|
||||
renderWrapped(
|
||||
<CatalogFilter
|
||||
buttonGroups={mockGroups}
|
||||
onChange={onChange}
|
||||
initiallySelected="starred"
|
||||
/>,
|
||||
);
|
||||
|
||||
const item = mockGroups[0].items[0];
|
||||
|
||||
const element = await findByText(item.label);
|
||||
|
||||
fireEvent.click(element);
|
||||
|
||||
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item.id);
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
id: 'starred',
|
||||
label: 'Second Label',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render a component when a function is passed to the count component', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
it('can change the selected item', async () => {
|
||||
const mockGroups: ButtonGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: EntityGroup.ALL,
|
||||
id: 'all',
|
||||
label: 'First Label',
|
||||
count: () => <b>BACKSTAGE!</b>,
|
||||
filterFn: () => true,
|
||||
},
|
||||
{
|
||||
id: EntityGroup.STARRED,
|
||||
id: 'starred',
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
filterFn: () => false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
|
||||
),
|
||||
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { findByText } = renderWrapped(
|
||||
<CatalogFilter
|
||||
buttonGroups={mockGroups}
|
||||
initiallySelected="all"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await findByText('Test Group 1')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
id: 'all',
|
||||
label: 'First Label',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(await findByText('Second Label'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
id: 'starred',
|
||||
label: 'Second Label',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('displays match counts properly', async () => {
|
||||
const mockGroups: ButtonGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'owned',
|
||||
label: 'First Label',
|
||||
filterFn: entity => entity.spec?.owner === 'tools@example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = renderWrapped(
|
||||
<CatalogFilter buttonGroups={mockGroups} initiallySelected="owned" />,
|
||||
);
|
||||
|
||||
expect(await findByText('1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,31 +14,36 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { IconComponent } from '@backstage/core';
|
||||
import {
|
||||
Card,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Typography,
|
||||
Theme,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import type { IconComponent } from '@backstage/core';
|
||||
import { EntityGroup } from '../../data/filters';
|
||||
import { EntitiesByFilter } from '../../hooks/useEntities';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FilterGroup, useEntityFilterGroup } from '../../filter';
|
||||
|
||||
export type CatalogFilterItem = {
|
||||
id: EntityGroup;
|
||||
label: string;
|
||||
icon?: IconComponent;
|
||||
count?: number | FC;
|
||||
};
|
||||
|
||||
export type CatalogFilterGroup = {
|
||||
export type ButtonGroup = {
|
||||
name: string;
|
||||
items: CatalogFilterItem[];
|
||||
items: {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: IconComponent;
|
||||
filterFn: (entity: Entity) => boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
@@ -67,21 +72,56 @@ const useStyles = makeStyles<Theme>(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const CatalogFilter: FC<{
|
||||
selectedFilter: EntityGroup;
|
||||
onFilterChange: (type: EntityGroup) => void;
|
||||
entitiesByFilter: EntitiesByFilter;
|
||||
groups: CatalogFilterGroup[];
|
||||
}> = ({
|
||||
selectedFilter: selectedId,
|
||||
onFilterChange: setSelectedFilter,
|
||||
entitiesByFilter,
|
||||
groups,
|
||||
}) => {
|
||||
type OnChangeCallback = (item: { id: string; label: string }) => void;
|
||||
|
||||
type Props = {
|
||||
buttonGroups: ButtonGroup[];
|
||||
initiallySelected: string;
|
||||
onChange?: OnChangeCallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* The main filter group in the sidebar, toggling owned/starred/all.
|
||||
*/
|
||||
export const CatalogFilter = ({
|
||||
buttonGroups,
|
||||
onChange,
|
||||
initiallySelected,
|
||||
}: Props) => {
|
||||
const classes = useStyles();
|
||||
const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(
|
||||
buttonGroups,
|
||||
initiallySelected,
|
||||
);
|
||||
|
||||
const onChangeRef = useRef<OnChangeCallback>();
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
const setCurrent = useCallback(
|
||||
(item: { id: string; label: string }) => {
|
||||
setCurrentFilter(item.id);
|
||||
onChangeRef.current?.({ id: item.id, label: item.label });
|
||||
},
|
||||
[setCurrentFilter],
|
||||
);
|
||||
|
||||
// Make one initial onChange to inform the surroundings about the selected
|
||||
// item
|
||||
useEffect(() => {
|
||||
const items = buttonGroups.flatMap(g => g.items);
|
||||
const item = items.find(i => i.id === initiallySelected) || items[0];
|
||||
if (item) {
|
||||
onChangeRef.current?.({ id: item.id, label: item.label });
|
||||
}
|
||||
// intentionally only happens on startup
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
{groups.map(group => (
|
||||
{buttonGroups.map(group => (
|
||||
<React.Fragment key={group.name}>
|
||||
<Typography variant="subtitle2" className={classes.title}>
|
||||
{group.name}
|
||||
@@ -93,10 +133,8 @@ export const CatalogFilter: FC<{
|
||||
key={item.id}
|
||||
button
|
||||
divider
|
||||
onClick={() => {
|
||||
setSelectedFilter(item.id);
|
||||
}}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => setCurrent(item)}
|
||||
selected={item.id === currentFilter}
|
||||
className={classes.menuItem}
|
||||
>
|
||||
{item.icon && (
|
||||
@@ -109,7 +147,9 @@ export const CatalogFilter: FC<{
|
||||
{item.label}
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
{entitiesByFilter[item.id]?.length ?? '-'}
|
||||
<ListItemSecondaryAction>
|
||||
{getFilterCount(item.id) ?? '-'}
|
||||
</ListItemSecondaryAction>
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
@@ -119,3 +159,53 @@ export const CatalogFilter: FC<{
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
function useFilter(
|
||||
buttonGroups: ButtonGroup[],
|
||||
initiallySelected: string,
|
||||
): {
|
||||
currentFilter: string;
|
||||
setCurrentFilter: (filterId: string) => void;
|
||||
getFilterCount: (filterId: string) => number | undefined;
|
||||
} {
|
||||
const [currentFilter, setCurrentFilter] = useState(initiallySelected);
|
||||
|
||||
const filterGroup = useMemo<FilterGroup>(
|
||||
() => ({
|
||||
filters: Object.fromEntries(
|
||||
buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]),
|
||||
),
|
||||
}),
|
||||
[buttonGroups],
|
||||
);
|
||||
|
||||
const { setSelectedFilters, state } = useEntityFilterGroup(
|
||||
'primary-sidebar',
|
||||
filterGroup,
|
||||
[initiallySelected],
|
||||
);
|
||||
|
||||
const setCurrent = useCallback(
|
||||
(filterId: string) => {
|
||||
setCurrentFilter(filterId);
|
||||
setSelectedFilters([filterId]);
|
||||
},
|
||||
[setCurrentFilter, setSelectedFilters],
|
||||
);
|
||||
|
||||
const getFilterCount = useCallback(
|
||||
(filterId: string) => {
|
||||
if (state.type !== 'ready') {
|
||||
return undefined;
|
||||
}
|
||||
return state.state.filters[filterId].matchCount;
|
||||
},
|
||||
[state],
|
||||
);
|
||||
|
||||
return {
|
||||
currentFilter,
|
||||
setCurrentFilter: setCurrent,
|
||||
getFilterCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,26 +14,29 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import {
|
||||
Header,
|
||||
HomepageTimer,
|
||||
identityApiRef,
|
||||
Page,
|
||||
pageTheme,
|
||||
identityApiRef,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { getTimeBasedGreeting } from './utils/timeUtil';
|
||||
|
||||
const CatalogLayout: FC<{}> = props => {
|
||||
const { children } = props;
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const CatalogLayout = ({ children }: Props) => {
|
||||
const greeting = getTimeBasedGreeting();
|
||||
const identityApi = useApi(identityApiRef);
|
||||
const userId = useApi(identityApiRef).getUserId();
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header
|
||||
title={`${greeting.greeting}, ${identityApi.getUserId()}!`}
|
||||
title={`${greeting.greeting}, ${userId}!`}
|
||||
subtitle="Backstage Service Catalog"
|
||||
tooltip={greeting.language}
|
||||
pageTitleOverride="Home"
|
||||
|
||||
@@ -14,45 +14,43 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
errorApiRef,
|
||||
storageApiRef,
|
||||
WebStorage,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core';
|
||||
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { CatalogApi } from '../../api/types';
|
||||
import { EntityFilterGroupsProvider } from '../../filter';
|
||||
import { CatalogPage } from './CatalogPage';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
const mockErrorApi = new MockErrorApi();
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
spec: {
|
||||
owner: 'tools@example.com',
|
||||
type: 'service',
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity2',
|
||||
},
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
spec: {
|
||||
owner: 'not-tools@example.com',
|
||||
type: 'service',
|
||||
@@ -62,49 +60,32 @@ describe('CatalogPage', () => {
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
};
|
||||
const mockIndentityApi: Partial<IdentityApi> = {
|
||||
const indentityApi: Partial<IdentityApi> = {
|
||||
getUserId: () => 'tools@example.com',
|
||||
};
|
||||
|
||||
const renderWrapped = (children: React.ReactNode) =>
|
||||
render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[catalogApiRef, catalogApi],
|
||||
[identityApiRef, indentityApi],
|
||||
[storageApiRef, MockStorageApi.create()],
|
||||
])}
|
||||
>
|
||||
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// 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
|
||||
it('should render', async () => {
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, mockErrorApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
|
||||
[identityApiRef, mockIndentityApi],
|
||||
])}
|
||||
>
|
||||
<CatalogPage />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const items = await findByText(/All Services \(2\)/);
|
||||
expect(items).toBeInTheDocument();
|
||||
});
|
||||
it('should filter by owner', async () => {
|
||||
const { findByText, getByText } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, mockErrorApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
|
||||
[identityApiRef, mockIndentityApi],
|
||||
])}
|
||||
>
|
||||
<CatalogPage />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(getByText(/Owned/));
|
||||
const items = await findByText(/Owned \(1\)/);
|
||||
expect(items).toBeInTheDocument();
|
||||
const { findByText, getByText } = renderWrapped(<CatalogPage />);
|
||||
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
|
||||
fireEvent.click(getByText(/All/));
|
||||
expect(await findByText(/All \(2\)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,39 +14,26 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
DismissableBanner,
|
||||
HeaderTabs,
|
||||
identityApiRef,
|
||||
SupportButton,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import CatalogLayout from './CatalogLayout';
|
||||
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
|
||||
import {
|
||||
Button,
|
||||
Link,
|
||||
makeStyles,
|
||||
Typography,
|
||||
withStyles,
|
||||
} from '@material-ui/core';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import Star from '@material-ui/icons/Star';
|
||||
import StarOutline from '@material-ui/icons/StarBorder';
|
||||
import React, { FC } from 'react';
|
||||
import { Button, makeStyles } from '@material-ui/core';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import StarIcon from '@material-ui/icons/Star';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { CatalogFilter } from '../CatalogFilter/CatalogFilter';
|
||||
import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
import { CatalogFilter, ButtonGroup } from '../CatalogFilter/CatalogFilter';
|
||||
import { CatalogTable } from '../CatalogTable/CatalogTable';
|
||||
import { useEntities } from '../../hooks/useEntities';
|
||||
import { findLocationForEntityMeta } from '../../data/utils';
|
||||
import {
|
||||
getCatalogFilterItemByType,
|
||||
EntityGroup,
|
||||
filterGroups,
|
||||
labeledEntityTypes,
|
||||
} from '../../data/filters';
|
||||
import CatalogLayout from './CatalogLayout';
|
||||
import { CatalogTabs, LabeledComponentType } from './CatalogTabs';
|
||||
import { WelcomeBanner } from './WelcomeBanner';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -55,138 +42,116 @@ const useStyles = makeStyles(theme => ({
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
emoji: {
|
||||
fontSize: '125%',
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
export const CatalogPage: FC<{}> = () => {
|
||||
const {
|
||||
entitiesByFilter,
|
||||
error,
|
||||
loading,
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
toggleStarredEntity,
|
||||
isStarredEntity,
|
||||
selectTypeFilter,
|
||||
} = useEntities();
|
||||
|
||||
const filteredEntities = entitiesByFilter[selectedFilter ?? EntityGroup.ALL];
|
||||
|
||||
const CatalogPageContents = () => {
|
||||
const styles = useStyles();
|
||||
const { loading, error, matchingEntities } = useFilteredEntities();
|
||||
const { isStarredEntity } = useStarredEntities();
|
||||
const userId = useApi(identityApiRef).getUserId();
|
||||
const [selectedTab, setSelectedTab] = useState<string>();
|
||||
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
|
||||
|
||||
const YellowStar = withStyles({
|
||||
root: {
|
||||
color: '#f3ba37',
|
||||
},
|
||||
})(Star);
|
||||
const tabs = useMemo<LabeledComponentType[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'service',
|
||||
label: 'Services',
|
||||
},
|
||||
{
|
||||
id: 'website',
|
||||
label: 'Websites',
|
||||
},
|
||||
{
|
||||
id: 'library',
|
||||
label: 'Libraries',
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
label: 'Documentation',
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const actions = [
|
||||
(rowData: Entity) => {
|
||||
const location = findLocationForEntityMeta(rowData.metadata);
|
||||
return {
|
||||
icon: GitHub,
|
||||
tooltip: 'View on GitHub',
|
||||
onClick: () => {
|
||||
if (!location) return;
|
||||
window.open(location.target, '_blank');
|
||||
},
|
||||
hidden: location?.type !== 'github',
|
||||
};
|
||||
},
|
||||
(rowData: Entity) => {
|
||||
const createEditLink = (location: LocationSpec): string => {
|
||||
switch (location.type) {
|
||||
case 'github':
|
||||
return location.target.replace('/blob/', '/edit/');
|
||||
default:
|
||||
return location.target;
|
||||
}
|
||||
};
|
||||
|
||||
const location = findLocationForEntityMeta(rowData.metadata);
|
||||
|
||||
return {
|
||||
icon: Edit,
|
||||
tooltip: 'Edit',
|
||||
iconProps: { size: 'small' },
|
||||
onClick: () => {
|
||||
if (!location) return;
|
||||
window.open(createEditLink(location), '_blank');
|
||||
},
|
||||
hidden: location?.type !== 'github',
|
||||
};
|
||||
},
|
||||
(rowData: Entity) => {
|
||||
const isStarred = isStarredEntity(rowData);
|
||||
return {
|
||||
icon: isStarred ? YellowStar : StarOutline,
|
||||
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
|
||||
onClick: () => toggleStarredEntity(rowData),
|
||||
};
|
||||
},
|
||||
];
|
||||
const filterGroups = useMemo<ButtonGroup[]>(
|
||||
() => [
|
||||
{
|
||||
name: 'Personal',
|
||||
items: [
|
||||
{
|
||||
id: 'owned',
|
||||
label: 'Owned',
|
||||
icon: SettingsIcon,
|
||||
filterFn: entity => entity.spec?.owner === userId,
|
||||
},
|
||||
{
|
||||
id: 'starred',
|
||||
label: 'Starred',
|
||||
icon: StarIcon,
|
||||
filterFn: isStarredEntity,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Company', // TODO: Replace with Company name, read from app config.
|
||||
items: [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All',
|
||||
filterFn: () => true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[isStarredEntity, userId],
|
||||
);
|
||||
|
||||
return (
|
||||
<CatalogLayout>
|
||||
<HeaderTabs
|
||||
tabs={labeledEntityTypes}
|
||||
onChange={(index: Number) => {
|
||||
selectTypeFilter(labeledEntityTypes[index as number].id);
|
||||
}}
|
||||
<CatalogTabs
|
||||
tabs={tabs}
|
||||
onChange={({ label }) => setSelectedTab(label)}
|
||||
/>
|
||||
<Content>
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
message={
|
||||
<Typography>
|
||||
<span role="img" aria-label="wave" className={styles.emoji}>
|
||||
👋🏼
|
||||
</span>
|
||||
Welcome to Backstage, we are happy to have you. Start by checking
|
||||
out our{' '}
|
||||
<Link href="/welcome" color="textSecondary">
|
||||
getting started
|
||||
</Link>{' '}
|
||||
page.
|
||||
</Typography>
|
||||
}
|
||||
id="catalog_page_welcome_banner"
|
||||
/>
|
||||
<ContentHeader title="Services">
|
||||
<WelcomeBanner />
|
||||
<ContentHeader title={selectedTab ?? ''}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
to={scaffolderRootRoute.path}
|
||||
>
|
||||
Create Service
|
||||
Create Component
|
||||
</Button>
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<div className={styles.contentWrapper}>
|
||||
<div>
|
||||
<CatalogFilter
|
||||
groups={filterGroups}
|
||||
selectedFilter={selectedFilter ?? EntityGroup.ALL}
|
||||
onFilterChange={setSelectedFilter}
|
||||
entitiesByFilter={entitiesByFilter}
|
||||
buttonGroups={filterGroups}
|
||||
onChange={({ label }) => setSelectedSidebarItem(label)}
|
||||
initiallySelected="owned"
|
||||
/>
|
||||
</div>
|
||||
<CatalogTable
|
||||
titlePreamble={
|
||||
getCatalogFilterItemByType(selectedFilter ?? EntityGroup.ALL)
|
||||
?.label ?? ''
|
||||
}
|
||||
entities={filteredEntities || []}
|
||||
loading={loading && !error}
|
||||
titlePreamble={selectedSidebarItem ?? ''}
|
||||
entities={matchingEntities}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
</CatalogLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const CatalogPage = () => (
|
||||
<EntityFilterGroupsProvider>
|
||||
<CatalogPageContents />
|
||||
</EntityFilterGroupsProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 { Entity } from '@backstage/catalog-model';
|
||||
import { HeaderTabs } from '@backstage/core';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FilterGroup, useEntityFilterGroup } from '../../filter';
|
||||
|
||||
/**
|
||||
* A component type, and a human readable label for it.
|
||||
*/
|
||||
export type LabeledComponentType = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Called on mount, and when the selected tab changes.
|
||||
*/
|
||||
export type OnChangeCallback = (tab: LabeledComponentType) => void;
|
||||
|
||||
type Props = {
|
||||
tabs: LabeledComponentType[];
|
||||
onChange?: OnChangeCallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* The tabs at the top of the catalog list page, for component type filtering.
|
||||
*/
|
||||
export const CatalogTabs = ({ tabs, onChange }: Props) => {
|
||||
const filterGroup = useMemo<FilterGroup>(() => {
|
||||
return {
|
||||
filters: Object.fromEntries(
|
||||
tabs.map(t => [t.id, (entity: Entity) => entity.spec?.type === t.id]),
|
||||
),
|
||||
};
|
||||
}, [tabs]);
|
||||
|
||||
const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [
|
||||
tabs[0].id,
|
||||
]);
|
||||
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState<number>(0);
|
||||
|
||||
// Hold a reference to the callback
|
||||
const onChangeRef = useRef<OnChangeCallback>();
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.(tabs[currentTabIndex]);
|
||||
}, [tabs, currentTabIndex]);
|
||||
|
||||
const switchTab = useCallback(
|
||||
(index: number) => {
|
||||
const tab = tabs[index];
|
||||
setSelectedFilters([tab.id]);
|
||||
setCurrentTabIndex(index);
|
||||
onChangeRef.current?.(tab);
|
||||
},
|
||||
[tabs, setSelectedFilters],
|
||||
);
|
||||
|
||||
return <HeaderTabs tabs={tabs} onChange={switchTab} />;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 { DismissableBanner } from '@backstage/core';
|
||||
import { Link, makeStyles, Typography } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
display: 'grid',
|
||||
gridTemplateAreas: "'filters' 'table'",
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
emoji: {
|
||||
fontSize: '125%',
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
export const WelcomeBanner = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
message={
|
||||
<Typography>
|
||||
<span role="img" aria-label="wave" className={classes.emoji}>
|
||||
👋🏼
|
||||
</span>
|
||||
Welcome to Backstage, we are happy to have you. Start by checking out
|
||||
our{' '}
|
||||
<Link href="/welcome" color="textSecondary">
|
||||
getting started
|
||||
</Link>{' '}
|
||||
page.
|
||||
</Typography>
|
||||
}
|
||||
id="catalog_page_welcome_banner"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -66,11 +66,9 @@ describe('CatalogTable component', () => {
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await rendered.findByText(`Owned (${entites.length})`),
|
||||
).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component1')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component2')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component3')).toBeInTheDocument();
|
||||
expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument();
|
||||
expect(rendered.getByText(/component1/)).toBeInTheDocument();
|
||||
expect(rendered.getByText(/component2/)).toBeInTheDocument();
|
||||
expect(rendered.getByText(/component3/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,16 +13,21 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { Table, TableColumn, TableProps } from '@backstage/core';
|
||||
import { Link } from '@material-ui/core';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import Star from '@material-ui/icons/Star';
|
||||
import StarOutline from '@material-ui/icons/StarBorder';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
import { findLocationForEntityMeta } from '../../data/utils';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
import { entityRoute } from '../../routes';
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
const columns: TableColumn<Entity>[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'metadata.name',
|
||||
@@ -63,16 +68,16 @@ type CatalogTableProps = {
|
||||
titlePreamble: string;
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
actions?: any;
|
||||
};
|
||||
|
||||
export const CatalogTable: FC<CatalogTableProps> = ({
|
||||
export const CatalogTable = ({
|
||||
entities,
|
||||
loading,
|
||||
error,
|
||||
titlePreamble,
|
||||
actions,
|
||||
}) => {
|
||||
}: CatalogTableProps) => {
|
||||
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
@@ -83,8 +88,57 @@ export const CatalogTable: FC<CatalogTableProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const actions: TableProps<Entity>['actions'] = [
|
||||
(rowData: Entity) => {
|
||||
const location = findLocationForEntityMeta(rowData.metadata);
|
||||
return {
|
||||
icon: () => <GitHub fontSize="small" />,
|
||||
tooltip: 'View on GitHub',
|
||||
onClick: () => {
|
||||
if (!location) return;
|
||||
window.open(location.target, '_blank');
|
||||
},
|
||||
hidden: location?.type !== 'github',
|
||||
};
|
||||
},
|
||||
(rowData: Entity) => {
|
||||
const createEditLink = (location: LocationSpec): string => {
|
||||
switch (location.type) {
|
||||
case 'github':
|
||||
return location.target.replace('/blob/', '/edit/');
|
||||
default:
|
||||
return location.target;
|
||||
}
|
||||
};
|
||||
const location = findLocationForEntityMeta(rowData.metadata);
|
||||
return {
|
||||
icon: () => <Edit fontSize="small" />,
|
||||
tooltip: 'Edit',
|
||||
onClick: () => {
|
||||
if (!location) return;
|
||||
window.open(createEditLink(location), '_blank');
|
||||
},
|
||||
hidden: location?.type !== 'github',
|
||||
};
|
||||
},
|
||||
(rowData: Entity) => {
|
||||
const isStarred = isStarredEntity(rowData);
|
||||
return {
|
||||
cellStyle: { paddingLeft: '1em' },
|
||||
icon: () =>
|
||||
isStarred ? (
|
||||
<Star htmlColor="#f3ba37" fontSize="small" />
|
||||
) : (
|
||||
<StarOutline fontSize="small" />
|
||||
),
|
||||
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
|
||||
onClick: () => toggleStarredEntity(rowData),
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
<Table<Entity>
|
||||
isLoading={loading}
|
||||
columns={columns}
|
||||
options={{
|
||||
|
||||
@@ -27,7 +27,7 @@ jest.mock('react-router-dom', () => {
|
||||
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render, wait } from '@testing-library/react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { CatalogApi, catalogApiRef } from '../../api/types';
|
||||
import { EntityPage, getPageTheme } from './EntityPage';
|
||||
@@ -66,7 +66,7 @@ describe('EntityPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
|
||||
await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/*
|
||||
* 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 { Entity } from '@backstage/catalog-model';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import StarIcon from '@material-ui/icons/Star';
|
||||
import {
|
||||
CatalogFilterGroup,
|
||||
CatalogFilterItem,
|
||||
} from '../components/CatalogFilter/CatalogFilter';
|
||||
|
||||
export enum EntityGroup {
|
||||
ALL = 'ALL',
|
||||
STARRED = 'STARRED',
|
||||
OWNED = 'OWNED',
|
||||
}
|
||||
|
||||
export const filterGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Personal',
|
||||
items: [
|
||||
{
|
||||
id: EntityGroup.OWNED,
|
||||
label: 'Owned',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
id: EntityGroup.STARRED,
|
||||
label: 'Starred',
|
||||
icon: StarIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// TODO: Replace with Company name, read from app config.
|
||||
name: 'Company',
|
||||
items: [
|
||||
{
|
||||
id: EntityGroup.ALL,
|
||||
label: 'All Services',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const getCatalogFilterItemByType = (filterType: EntityGroup) => {
|
||||
for (const group of filterGroups) {
|
||||
for (const filter of group.items) {
|
||||
if (filter.id === filterType) {
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
|
||||
|
||||
type EntityFilterOptions = Partial<{
|
||||
isStarred: boolean;
|
||||
userId: string;
|
||||
}>;
|
||||
|
||||
type Owned = {
|
||||
owner: string;
|
||||
};
|
||||
|
||||
export const entityFilters: Record<string, EntityFilter> = {
|
||||
[EntityGroup.OWNED]: (e, { userId }) => {
|
||||
const owner = (e.spec! as Owned).owner;
|
||||
return owner === userId;
|
||||
},
|
||||
[EntityGroup.ALL]: () => true,
|
||||
[EntityGroup.STARRED]: (_, { isStarred }) => !!isStarred,
|
||||
};
|
||||
|
||||
export const entityTypeFilter = (e: Entity, type: string) =>
|
||||
(e.spec as any)?.type === type;
|
||||
|
||||
type EntityType = 'service' | 'website' | 'library' | 'documentation' | 'other';
|
||||
|
||||
type LabeledEntityType = {
|
||||
id: EntityType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const labeledEntityTypes: LabeledEntityType[] = [
|
||||
{
|
||||
id: 'service',
|
||||
label: 'Services',
|
||||
},
|
||||
{
|
||||
id: 'website',
|
||||
label: 'Websites',
|
||||
},
|
||||
{
|
||||
id: 'library',
|
||||
label: 'Libraries',
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
label: 'Documentation',
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other',
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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 { Entity } from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { catalogApiRef } from '../api/types';
|
||||
import { filterGroupsContext, FilterGroupsContext } from './context';
|
||||
import {
|
||||
EntityFilterFn,
|
||||
FilterGroup,
|
||||
FilterGroupState,
|
||||
FilterGroupStates,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Implementation of the shared filter groups state.
|
||||
*/
|
||||
export const EntityFilterGroupsProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const state = useProvideEntityFilters();
|
||||
return (
|
||||
<filterGroupsContext.Provider value={state}>
|
||||
{children}
|
||||
</filterGroupsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// The hook that implements the actual context building
|
||||
function useProvideEntityFilters(): FilterGroupsContext {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value: entities, error } = useAsync(() => catalogApi.getEntities());
|
||||
|
||||
const filterGroups = useRef<{
|
||||
[filterGroupId: string]: FilterGroup;
|
||||
}>({});
|
||||
const selectedFilterKeys = useRef<{
|
||||
[filterGroupId: string]: Set<string>;
|
||||
}>({});
|
||||
const [filterGroupStates, setFilterGroupStates] = useState<{
|
||||
[filterGroupId: string]: FilterGroupStates;
|
||||
}>({});
|
||||
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
|
||||
|
||||
const rebuild = useCallback(() => {
|
||||
setFilterGroupStates(
|
||||
buildStates(
|
||||
filterGroups.current,
|
||||
selectedFilterKeys.current,
|
||||
entities,
|
||||
error,
|
||||
),
|
||||
);
|
||||
setMatchingEntities(
|
||||
buildMatchingEntities(
|
||||
filterGroups.current,
|
||||
selectedFilterKeys.current,
|
||||
entities,
|
||||
),
|
||||
);
|
||||
}, [entities, error]);
|
||||
|
||||
const register = useCallback(
|
||||
(
|
||||
filterGroupId: string,
|
||||
filterGroup: FilterGroup,
|
||||
initialSelectedFilterIds?: string[],
|
||||
) => {
|
||||
filterGroups.current[filterGroupId] = filterGroup;
|
||||
selectedFilterKeys.current[filterGroupId] = new Set(
|
||||
initialSelectedFilterIds ?? [],
|
||||
);
|
||||
rebuild();
|
||||
},
|
||||
[rebuild],
|
||||
);
|
||||
|
||||
const unregister = useCallback(
|
||||
(filterGroupId: string) => {
|
||||
delete filterGroups.current[filterGroupId];
|
||||
delete selectedFilterKeys.current[filterGroupId];
|
||||
rebuild();
|
||||
},
|
||||
[rebuild],
|
||||
);
|
||||
|
||||
const setGroupSelectedFilters = useCallback(
|
||||
(filterGroupId: string, filters: string[]) => {
|
||||
selectedFilterKeys.current[filterGroupId] = new Set(filters);
|
||||
rebuild();
|
||||
},
|
||||
[rebuild],
|
||||
);
|
||||
|
||||
return {
|
||||
register,
|
||||
unregister,
|
||||
setGroupSelectedFilters,
|
||||
loading: !error && !entities,
|
||||
error,
|
||||
filterGroupStates,
|
||||
matchingEntities,
|
||||
};
|
||||
}
|
||||
|
||||
// Given all filter groups and what filters are actually selected, along with
|
||||
// the loading state for entities, generate the state of each individual filter
|
||||
function buildStates(
|
||||
filterGroups: { [filterGroupId: string]: FilterGroup },
|
||||
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
|
||||
entities?: Entity[],
|
||||
error?: Error,
|
||||
): { [filterGroupId: string]: FilterGroupStates } {
|
||||
// On error - all entries are an error state
|
||||
if (error) {
|
||||
return Object.fromEntries(
|
||||
Object.keys(filterGroups).map(filterGroupId => [
|
||||
filterGroupId,
|
||||
{ type: 'error', error },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// On startup - all entries are a loading state
|
||||
if (!entities) {
|
||||
return Object.fromEntries(
|
||||
Object.keys(filterGroups).map(filterGroupId => [
|
||||
filterGroupId,
|
||||
{ type: 'loading' },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const result: { [filterGroupId: string]: FilterGroupStates } = {};
|
||||
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
|
||||
const otherMatchingEntities = buildMatchingEntities(
|
||||
filterGroups,
|
||||
selectedFilterKeys,
|
||||
entities,
|
||||
filterGroupId,
|
||||
);
|
||||
const groupState: FilterGroupState = { filters: {} };
|
||||
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
|
||||
const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId);
|
||||
const matchCount = otherMatchingEntities.filter(entity =>
|
||||
filterFn(entity),
|
||||
).length;
|
||||
groupState.filters[filterId] = { isSelected, matchCount };
|
||||
}
|
||||
result[filterGroupId] = { type: 'ready', state: groupState };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Given all filter groups and what filters are actually selected, extract all
|
||||
// entities that match all those filter groups.
|
||||
function buildMatchingEntities(
|
||||
filterGroups: { [filterGroupId: string]: FilterGroup },
|
||||
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
|
||||
entities?: Entity[],
|
||||
excludeFilterGroupId?: string,
|
||||
): Entity[] {
|
||||
// Build one filter fn per filter group
|
||||
const allFilters: EntityFilterFn[] = [];
|
||||
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
|
||||
if (excludeFilterGroupId === filterGroupId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pick out all of the filter functions in the group that are actually
|
||||
// selected
|
||||
const groupFilters: EntityFilterFn[] = [];
|
||||
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
|
||||
if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) {
|
||||
groupFilters.push(filterFn);
|
||||
}
|
||||
}
|
||||
|
||||
// Need to match any of the selected filters in the group - if there is
|
||||
// any at all
|
||||
if (groupFilters.length) {
|
||||
allFilters.push(entity => groupFilters.some(fn => fn(entity)));
|
||||
}
|
||||
}
|
||||
|
||||
// All filter groups that had any checked filters need to match. Note that
|
||||
// every() always returns true for an empty array.
|
||||
return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { Entity } from '@backstage/catalog-model';
|
||||
import { createContext } from 'react';
|
||||
import { FilterGroup, FilterGroupStates } from './types';
|
||||
|
||||
export type FilterGroupsContext = {
|
||||
register: (
|
||||
filterGroupId: string,
|
||||
filterGroup: FilterGroup,
|
||||
initialSelectedFilterIds?: string[],
|
||||
) => void;
|
||||
unregister: (filterGroupId: string) => void;
|
||||
setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void;
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
|
||||
matchingEntities: Entity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The context that maintains shared state for all visible filter groups.
|
||||
*/
|
||||
export const filterGroupsContext = createContext<
|
||||
FilterGroupsContext | undefined
|
||||
>(undefined);
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
|
||||
export type {
|
||||
EntityFilterFn,
|
||||
FilterGroup,
|
||||
FilterGroupState,
|
||||
FilterGroupStates,
|
||||
FilterGroupStatesError,
|
||||
FilterGroupStatesLoading,
|
||||
FilterGroupStatesReady,
|
||||
} from './types';
|
||||
export { useEntityFilterGroup } from './useEntityFilterGroup';
|
||||
export { useFilteredEntities } from './useFilteredEntities';
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export type EntityFilterFn = (entity: Entity) => boolean;
|
||||
|
||||
export type FilterGroup = {
|
||||
filters: {
|
||||
[filterId: string]: EntityFilterFn;
|
||||
};
|
||||
};
|
||||
|
||||
export type FilterGroupState = {
|
||||
filters: {
|
||||
[filterId: string]: {
|
||||
isSelected: boolean;
|
||||
matchCount: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type FilterGroupStatesReady = {
|
||||
type: 'ready';
|
||||
state: FilterGroupState;
|
||||
};
|
||||
|
||||
export type FilterGroupStatesError = {
|
||||
type: 'error';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export type FilterGroupStatesLoading = {
|
||||
type: 'loading';
|
||||
};
|
||||
|
||||
export type FilterGroupStates =
|
||||
| FilterGroupStatesReady
|
||||
| FilterGroupStatesError
|
||||
| FilterGroupStatesLoading;
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { catalogApiRef } from '../api/types';
|
||||
import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
|
||||
import { FilterGroupStatesReady, FilterGroup } from './types';
|
||||
import { useEntityFilterGroup } from './useEntityFilterGroup';
|
||||
import { MockStorageApi } from '@backstage/test-utils';
|
||||
|
||||
describe('useEntityFilterGroup', () => {
|
||||
let catalogApi: jest.Mocked<typeof catalogApiRef.T>;
|
||||
let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element;
|
||||
|
||||
beforeEach(() => {
|
||||
catalogApi = {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
|
||||
getEntities: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
};
|
||||
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
|
||||
storageApiRef,
|
||||
MockStorageApi.create(),
|
||||
);
|
||||
wrapper = ({ children }: { children?: React.ReactNode }) => (
|
||||
<ApiProvider apis={apis}>
|
||||
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
});
|
||||
|
||||
it('works for an empty set of filters', async () => {
|
||||
catalogApi.getEntities.mockResolvedValue([]);
|
||||
const group: FilterGroup = { filters: {} };
|
||||
const { result, wait } = renderHook(
|
||||
() => useEntityFilterGroup('g1', group),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await wait(() => expect(result.current.state.type).toBe('ready'));
|
||||
});
|
||||
|
||||
it('works for a single group', async () => {
|
||||
catalogApi.getEntities.mockResolvedValue([
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'n' },
|
||||
},
|
||||
]);
|
||||
const group: FilterGroup = {
|
||||
filters: {
|
||||
f1: e => e.metadata.name === 'n',
|
||||
f2: e => e.metadata.name !== 'n',
|
||||
},
|
||||
};
|
||||
const { result, wait } = renderHook(
|
||||
() => useEntityFilterGroup('g1', group),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await wait(() => expect(result.current.state.type).toEqual('ready'));
|
||||
let state = result.current.state as FilterGroupStatesReady;
|
||||
expect(state.state.filters.f1).toEqual({
|
||||
isSelected: false,
|
||||
matchCount: 1,
|
||||
});
|
||||
expect(state.state.filters.f2).toEqual({
|
||||
isSelected: false,
|
||||
matchCount: 0,
|
||||
});
|
||||
|
||||
act(() => result.current.setSelectedFilters(['f1']));
|
||||
|
||||
await wait(() => expect(result.current.state.type).toEqual('ready'));
|
||||
state = result.current.state as FilterGroupStatesReady;
|
||||
expect(state.state.filters.f1).toEqual({
|
||||
isSelected: true,
|
||||
matchCount: 1,
|
||||
});
|
||||
expect(state.state.filters.f2).toEqual({
|
||||
isSelected: false,
|
||||
matchCount: 0,
|
||||
});
|
||||
|
||||
act(() => result.current.setSelectedFilters(['f2']));
|
||||
|
||||
await wait(() => expect(result.current.state.type).toEqual('ready'));
|
||||
state = result.current.state as FilterGroupStatesReady;
|
||||
expect(state.state.filters.f1).toEqual({
|
||||
isSelected: false,
|
||||
matchCount: 1,
|
||||
});
|
||||
expect(state.state.filters.f2).toEqual({
|
||||
isSelected: true,
|
||||
matchCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 { useCallback, useContext, useEffect, useMemo } from 'react';
|
||||
import { filterGroupsContext } from './context';
|
||||
import { FilterGroup, FilterGroupStates } from './types';
|
||||
|
||||
export type EntityFilterGroupOutput = {
|
||||
state: FilterGroupStates;
|
||||
setSelectedFilters: (filterIds: string[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that exposes the relevant data and operations for a single filter
|
||||
* group.
|
||||
*/
|
||||
export const useEntityFilterGroup = (
|
||||
filterGroupId: string,
|
||||
filterGroup: FilterGroup,
|
||||
initialSelectedFilters?: string[],
|
||||
): EntityFilterGroupOutput => {
|
||||
const context = useContext(filterGroupsContext);
|
||||
if (!context) {
|
||||
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
|
||||
}
|
||||
const {
|
||||
register,
|
||||
unregister,
|
||||
setGroupSelectedFilters,
|
||||
filterGroupStates,
|
||||
} = context;
|
||||
|
||||
// Intentionally consider initial set only at mount time
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const initialMemo = useMemo(() => initialSelectedFilters?.slice(), []);
|
||||
|
||||
// Register the group on mount, and unregister on unmount
|
||||
useEffect(() => {
|
||||
register(filterGroupId, filterGroup, initialMemo);
|
||||
return () => unregister(filterGroupId);
|
||||
}, [register, unregister, filterGroupId, filterGroup, initialMemo]);
|
||||
|
||||
const setSelectedFilters = useCallback(
|
||||
(filters: string[]) => {
|
||||
setGroupSelectedFilters(filterGroupId, filters);
|
||||
},
|
||||
[setGroupSelectedFilters, filterGroupId],
|
||||
);
|
||||
|
||||
let state = filterGroupStates[filterGroupId];
|
||||
if (!state) {
|
||||
state = { type: 'loading' };
|
||||
}
|
||||
|
||||
return { state, setSelectedFilters };
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 { useContext } from 'react';
|
||||
import { filterGroupsContext } from './context';
|
||||
|
||||
/**
|
||||
* Hook that exposes the result of applying a set of filter groups.
|
||||
*/
|
||||
export function useFilteredEntities() {
|
||||
const context = useContext(filterGroupsContext);
|
||||
if (!context) {
|
||||
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
|
||||
}
|
||||
|
||||
return {
|
||||
loading: context.loading,
|
||||
error: context.error,
|
||||
matchingEntities: context.matchingEntities,
|
||||
};
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/*
|
||||
* 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, useMemo } from 'react';
|
||||
import {
|
||||
EntityGroup,
|
||||
entityFilters,
|
||||
entityTypeFilter,
|
||||
labeledEntityTypes,
|
||||
} from '../data/filters';
|
||||
import { useApi, identityApiRef } from '@backstage/core';
|
||||
import { catalogApiRef } from '..';
|
||||
import { useStarredEntities } from './useStarredEntites';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import useStaleWhileRevalidate from 'swr';
|
||||
|
||||
export type EntitiesByFilter = Record<EntityGroup, Entity[] | undefined>;
|
||||
|
||||
type UseEntities = {
|
||||
selectedFilter: EntityGroup | undefined;
|
||||
setSelectedFilter: (f: EntityGroup) => void;
|
||||
error: Error | null;
|
||||
toggleStarredEntity: any;
|
||||
isStarredEntity: (e: Entity) => boolean;
|
||||
entitiesByFilter: EntitiesByFilter;
|
||||
loading: boolean;
|
||||
selectedTypeFilter: string;
|
||||
selectTypeFilter: (id: string) => void;
|
||||
};
|
||||
|
||||
export const useEntities = (): UseEntities => {
|
||||
const [selectedFilter, setSelectedFilter] = useState<
|
||||
EntityGroup | undefined
|
||||
>();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
|
||||
const { data: entities, error } = useStaleWhileRevalidate(
|
||||
['catalog/all', entityFilters[selectedFilter ?? EntityGroup.ALL]],
|
||||
async () => catalogApi.getEntities(),
|
||||
);
|
||||
|
||||
const indentityApi = useApi(identityApiRef);
|
||||
const userId = indentityApi.getUserId();
|
||||
|
||||
const [selectedTypeFilter, selectTypeFilter] = useState<string>(
|
||||
labeledEntityTypes[0].id,
|
||||
);
|
||||
|
||||
const entitiesByFilter = useMemo(() => {
|
||||
const filterEntities = (
|
||||
ents: Entity[] | undefined,
|
||||
filterId: EntityGroup,
|
||||
isStarred: (e: Entity) => boolean,
|
||||
user: string,
|
||||
) => {
|
||||
return ents
|
||||
?.filter((e: Entity) =>
|
||||
entityFilters[filterId](e, {
|
||||
isStarred: isStarred(e),
|
||||
userId: user,
|
||||
}),
|
||||
)
|
||||
.filter(e => entityTypeFilter(e, selectedTypeFilter));
|
||||
};
|
||||
const data = Object.keys(EntityGroup).reduce(
|
||||
(res, key) => ({
|
||||
...res,
|
||||
[key]: filterEntities(
|
||||
entities,
|
||||
key as EntityGroup,
|
||||
isStarredEntity,
|
||||
userId,
|
||||
),
|
||||
}),
|
||||
{} as EntitiesByFilter,
|
||||
);
|
||||
return data;
|
||||
}, [entities, isStarredEntity, userId, selectedTypeFilter]);
|
||||
|
||||
return {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
error,
|
||||
toggleStarredEntity,
|
||||
isStarredEntity,
|
||||
entitiesByFilter,
|
||||
loading: entities === undefined,
|
||||
selectedTypeFilter,
|
||||
selectTypeFilter,
|
||||
};
|
||||
};
|
||||
@@ -29,6 +29,8 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router": "^6.0.0-alpha.5",
|
||||
"react-router-dom": "^6.0.0-alpha.5",
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export const docStorageURL =
|
||||
'https://techdocs-mock-sites.storage.googleapis.com';
|
||||
@@ -33,7 +33,7 @@ import { createPlugin, createRouteRef } from '@backstage/core';
|
||||
import { Reader } from './reader/components/Reader';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '/docs',
|
||||
path: '/docs/:componentId/*',
|
||||
title: 'Docs',
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,15 @@
|
||||
import React from 'react';
|
||||
import { useShadowDom } from '..';
|
||||
import { useAsync } from 'react-use';
|
||||
import transformer, {
|
||||
addBaseUrl,
|
||||
rewriteDocLinks,
|
||||
addEventListener,
|
||||
} from '../transformers';
|
||||
import { docStorageURL } from '../../config';
|
||||
import { Link } from '@backstage/core';
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
import URLParser from '../urlParser';
|
||||
|
||||
const useFetch = (url: string) => {
|
||||
const state = useAsync(async () => {
|
||||
@@ -28,54 +37,62 @@ const useFetch = (url: string) => {
|
||||
return state;
|
||||
};
|
||||
|
||||
const addBaseUrl = (htmlString: string, baseUrl: string): string => {
|
||||
const domParser = new DOMParser().parseFromString(htmlString, 'text/html');
|
||||
const useEnforcedTrailingSlash = (): void => {
|
||||
React.useEffect(() => {
|
||||
const actualUrl = window.location.href;
|
||||
const expectedUrl = new URLParser(window.location.href, '.').parse();
|
||||
|
||||
const updateDom = <T extends Element>(
|
||||
list: Array<T>,
|
||||
attributeName: string,
|
||||
): void => {
|
||||
Array.from(list).forEach((elem: T) => {
|
||||
const newUrl = new URL(
|
||||
elem.getAttribute(attributeName)!,
|
||||
baseUrl,
|
||||
).toString();
|
||||
elem.setAttribute(attributeName, newUrl);
|
||||
});
|
||||
};
|
||||
|
||||
updateDom<HTMLImageElement>(Array.from(domParser.images), 'src');
|
||||
updateDom<HTMLAnchorElement | HTMLAreaElement>(
|
||||
Array.from(domParser.links),
|
||||
'href',
|
||||
);
|
||||
updateDom<HTMLLinkElement>(
|
||||
Array.from(domParser.querySelectorAll('link')),
|
||||
'href',
|
||||
);
|
||||
|
||||
return domParser.body.parentElement?.outerHTML || htmlString;
|
||||
if (actualUrl !== expectedUrl) {
|
||||
window.history.replaceState({}, document.title, expectedUrl);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const Reader = () => {
|
||||
const location = useLocation();
|
||||
const { componentId, '*': path } = useParams();
|
||||
const shadowDomRef = useShadowDom();
|
||||
const state = useFetch(
|
||||
'https://techdocs-mock-sites.storage.googleapis.com/mkdocs/index.html',
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
const normalizedUrl = new URLParser(
|
||||
`${docStorageURL}${location.pathname.replace('/docs', '')}`,
|
||||
'.',
|
||||
).parse();
|
||||
const state = useFetch(`${normalizedUrl}index.html`);
|
||||
|
||||
useEnforcedTrailingSlash();
|
||||
|
||||
React.useEffect(() => {
|
||||
const divElement = shadowDomRef.current;
|
||||
if (divElement?.shadowRoot && state.value) {
|
||||
divElement.shadowRoot.innerHTML = addBaseUrl(
|
||||
state.value,
|
||||
'https://techdocs-mock-sites.storage.googleapis.com/mkdocs/',
|
||||
);
|
||||
const transformedElement = transformer(state.value, [
|
||||
addBaseUrl({
|
||||
docStorageURL,
|
||||
componentId,
|
||||
path,
|
||||
}),
|
||||
rewriteDocLinks({
|
||||
componentId,
|
||||
}),
|
||||
]);
|
||||
|
||||
divElement.shadowRoot.innerHTML = '';
|
||||
if (transformedElement) {
|
||||
divElement.shadowRoot.appendChild(transformedElement);
|
||||
transformer(divElement.shadowRoot.children[0], [
|
||||
addEventListener({
|
||||
onClick: navigate,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, [shadowDomRef, state]);
|
||||
}, [shadowDomRef, state, componentId, path, navigate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>Shadow DOM should be underneath</h3>
|
||||
<nav>
|
||||
<Link to="/docs/mkdocs/">mkdocs</Link>
|
||||
<Link to="/docs/backstage-microsite/">Backstage docs</Link>
|
||||
</nav>
|
||||
<div ref={shadowDomRef} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 URLParser from '../urlParser';
|
||||
import type { Transformer } from './index';
|
||||
|
||||
type AddBaseUrlOptions = {
|
||||
docStorageURL: string;
|
||||
componentId: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const addBaseUrl = ({
|
||||
docStorageURL,
|
||||
componentId,
|
||||
path,
|
||||
}: AddBaseUrlOptions): Transformer => {
|
||||
return dom => {
|
||||
const updateDom = <T extends Element>(
|
||||
list: HTMLCollectionOf<T> | NodeListOf<T>,
|
||||
attributeName: string,
|
||||
): void => {
|
||||
Array.from(list)
|
||||
.filter(elem => !!elem.getAttribute(attributeName))
|
||||
.forEach((elem: T) => {
|
||||
const newUrl = new URLParser(
|
||||
`${docStorageURL}/${componentId}/${path}`,
|
||||
elem.getAttribute(attributeName)!,
|
||||
).parse();
|
||||
elem.setAttribute(attributeName, newUrl);
|
||||
});
|
||||
};
|
||||
|
||||
updateDom<HTMLImageElement>(dom.querySelectorAll('img'), 'src');
|
||||
updateDom<HTMLScriptElement>(dom.querySelectorAll('script'), 'src');
|
||||
updateDom<HTMLLinkElement>(dom.querySelectorAll('link'), 'href');
|
||||
|
||||
return dom;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 type { Transformer } from './index';
|
||||
|
||||
type AddEventListenerOptions = {
|
||||
onClick: (newUrl: string) => void;
|
||||
};
|
||||
|
||||
export const addEventListener = ({
|
||||
onClick,
|
||||
}: AddEventListenerOptions): Transformer => {
|
||||
return dom => {
|
||||
Array.from(dom.getElementsByTagName('a')).forEach(elem => {
|
||||
elem.addEventListener('click', (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const target = e.target as HTMLAnchorElement;
|
||||
if (target?.getAttribute('href')) {
|
||||
onClick(
|
||||
target.getAttribute('href')!.replace(window.location.origin, ''),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return dom;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './addBaseUrl';
|
||||
export * from './rewriteDocLinks';
|
||||
export * from './addEventListener';
|
||||
|
||||
export type Transformer = (dom: Element) => Element;
|
||||
|
||||
function transform(
|
||||
html: string | Element,
|
||||
transformers: Transformer[],
|
||||
): Element {
|
||||
let dom: Element;
|
||||
|
||||
if (typeof html === 'string') {
|
||||
dom = new DOMParser().parseFromString(html, 'text/html').documentElement;
|
||||
} else if (html instanceof Element) {
|
||||
dom = html;
|
||||
} else {
|
||||
throw new Error('dom is not a recognized type');
|
||||
}
|
||||
|
||||
transformers.forEach(transformer => transformer(dom));
|
||||
|
||||
return dom;
|
||||
}
|
||||
|
||||
export default transform;
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 URLParser from '../urlParser';
|
||||
import type { Transformer } from './index';
|
||||
|
||||
type AddBaseUrlOptions = {};
|
||||
|
||||
export const rewriteDocLinks = ({}: AddBaseUrlOptions): Transformer => {
|
||||
return dom => {
|
||||
const updateDom = <T extends Element>(
|
||||
list: Array<T>,
|
||||
attributeName: string,
|
||||
): void => {
|
||||
Array.from(list)
|
||||
.filter(elem => elem.hasAttribute(attributeName))
|
||||
.forEach((elem: T) => {
|
||||
elem.setAttribute(
|
||||
attributeName,
|
||||
new URLParser(
|
||||
window.location.href,
|
||||
elem.getAttribute(attributeName)!,
|
||||
).parse(),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
updateDom(Array.from(dom.getElementsByTagName('a')), 'href');
|
||||
|
||||
return dom;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 URLParser from './urlParser';
|
||||
|
||||
describe('URLParser', () => {
|
||||
it('should not change an absolute url', () => {
|
||||
const urlParser = new URLParser(
|
||||
'https://www.google.com/',
|
||||
'https://www.mkdocs.org/',
|
||||
);
|
||||
|
||||
expect(urlParser.parse()).toEqual('https://www.mkdocs.org/');
|
||||
});
|
||||
|
||||
it('should convert a relative url to an absolute url', () => {
|
||||
const urlParser = new URLParser(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/',
|
||||
'../../support/installing/',
|
||||
);
|
||||
|
||||
expect(urlParser.parse()).toEqual(
|
||||
'https://www.mkdocs.org/support/installing/',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a trailing slash', () => {
|
||||
const urlParser = new URLParser(
|
||||
'https://www.mkdocs.org/user-guide/getting-started',
|
||||
'.',
|
||||
);
|
||||
|
||||
expect(urlParser.parse()).toEqual(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add a trailing slash', () => {
|
||||
const urlParser = new URLParser(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/',
|
||||
'.',
|
||||
);
|
||||
|
||||
expect(urlParser.parse()).toEqual(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const normalizeBaseURL = (baseURL: string): string => {
|
||||
const url = new URL(baseURL);
|
||||
url.pathname = url.pathname.replace(/([^/])$/, '$1/');
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export default class URLParser {
|
||||
constructor(public baseURL: string, public pathname: string) {
|
||||
this.baseURL = normalizeBaseURL(baseURL);
|
||||
}
|
||||
|
||||
parse(): string {
|
||||
return new URL(this.pathname, this.baseURL).toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user