Merge branch 'master' of github.com:spotify/backstage into shmidt-i/scaffolder-flow-frontend

This commit is contained in:
Ivan Shmidt
2020-06-29 14:33:52 +02:00
40 changed files with 1801 additions and 631 deletions
+1 -1
View File
@@ -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",
+22 -22
View File
@@ -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}
/>
);
};
}
+1 -1
View File
@@ -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);
};
+2 -1
View File
@@ -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'));
});
});
-123
View File
@@ -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))) ?? [];
}
+40
View File
@@ -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);
+28
View File
@@ -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';
+53
View File
@@ -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,
};
}
-103
View File
@@ -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,
};
};
+2
View File
@@ -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": {
+17
View File
@@ -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';
+1 -1
View File
@@ -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/',
);
});
});
+31
View File
@@ -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();
}
}