diff --git a/packages/core/package.json b/packages/core/package.json index 10a6c5d045..e49e4616ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index c02aca5659..047a6b1990 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -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) => ( @@ -131,10 +130,10 @@ const useToolbarStyles = makeStyles(theme => ({ }, })); -const convertColumns = ( - columns: TableColumn[], +function convertColumns( + columns: TableColumn[], theme: BackstageTheme, -): TableColumn[] => { +): TableColumn[] { 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 extends Column { highlight?: boolean; width?: string; } -export interface TableProps extends MaterialTableProps<{}> { - columns: TableColumn[]; +export interface TableProps + extends MaterialTableProps { + columns: TableColumn[]; subtitle?: string; } -export const Table: FC = ({ +export function Table({ columns, options, title, subtitle, ...props -}) => { +}: TableProps) { const cellClasses = useCellStyles(); const headerClasses = useHeaderStyles(); const toolbarClasses = useToolbarStyles(); @@ -183,7 +183,7 @@ export const Table: FC = ({ }; return ( - components={{ Cell: cellProps => ( @@ -211,4 +211,4 @@ export const Table: FC = ({ {...props} /> ); -}; +} diff --git a/packages/core/src/components/Table/index.ts b/packages/core/src/components/Table/index.ts index aeade2b24f..c10399dc59 100644 --- a/packages/core/src/components/Table/index.ts +++ b/packages/core/src/components/Table/index.ts @@ -15,5 +15,5 @@ */ export { Table } from './Table'; -export type { TableColumn } from './Table'; +export type { TableColumn, TableProps } from './Table'; export { SubvalueCell } from './SubvalueCell'; diff --git a/packages/core/src/layout/HeaderTabs/index.tsx b/packages/core/src/layout/HeaderTabs/index.tsx index 2617a27aa9..838b126569 100644 --- a/packages/core/src/layout/HeaderTabs/index.tsx +++ b/packages/core/src/layout/HeaderTabs/index.tsx @@ -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(0); + const [selectedTab, setSelectedTab] = useState(0); const styles = useStyles(); - const handleChange = (_: React.ChangeEvent<{}>, index: Number) => { + const handleChange = (_: React.ChangeEvent<{}>, index: number) => { setSelectedTab(index); if (onChange) onChange(index); }; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 501e288754..f0ed4905d5 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -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", diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts new file mode 100644 index 0000000000..668d973830 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts @@ -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$('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); + }); +}); diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts new file mode 100644 index 0000000000..a3a4ef16d6 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts @@ -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(key: string): T | undefined { + return this.data[this.getKeyName(key)]; + } + + async set(key: string, data: T): Promise { + this.data[this.getKeyName(key)] = data; + this.notifyChanges({ key, newValue: data }); + } + + async remove(key: string): Promise { + delete this.data[this.getKeyName(key)]; + this.notifyChanges({ key, newValue: undefined }); + } + + observe$(key: string): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueChange) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); +} diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/index.ts b/packages/test-utils/src/testUtils/apis/StorageApi/index.ts new file mode 100644 index 0000000000..ec4557b4c7 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/StorageApi/index.ts @@ -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'; diff --git a/packages/test-utils/src/testUtils/apis/index.ts b/packages/test-utils/src/testUtils/apis/index.ts index 55d6d10dd6..88229061de 100644 --- a/packages/test-utils/src/testUtils/apis/index.ts +++ b/packages/test-utils/src/testUtils/apis/index.ts @@ -15,3 +15,4 @@ */ export * from './ErrorApi'; +export * from './StorageApi'; diff --git a/packages/test-utils/src/testUtils/mockApiRegistry.ts b/packages/test-utils/src/testUtils/mockApiRegistry.ts index 96d12a9df2..15733ead88 100644 --- a/packages/test-utils/src/testUtils/mockApiRegistry.ts +++ b/packages/test-utils/src/testUtils/mockApiRegistry.ts @@ -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; } diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index cc4cc62fdb..a98eec0875 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -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 = { + 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 = { + 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( - , + + {children}, + , ), ); + it('should render the different groups', async () => { + const mockGroups: ButtonGroup[] = [ + { name: 'Test Group 1', items: [] }, + { name: 'Test Group 2', items: [] }, + ]; + const { findByText } = renderWrapped( + , + ); 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( - , - ), + const { findByText } = renderWrapped( + , ); - 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( - , - ), + const onChange = jest.fn(); + + renderWrapped( + , ); - 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( - , - ), + renderWrapped( + , ); - 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: () => BACKSTAGE!, + filterFn: () => true, }, { - id: EntityGroup.STARRED, + id: 'starred', label: 'Second Label', - count: 400, + filterFn: () => false, }, ], }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + + const onChange = jest.fn(); + + const { findByText } = renderWrapped( + , ); - 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( + , + ); + + expect(await findByText('1')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index 61a83fccd8..90a1e15ad0 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -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 => ({ @@ -67,21 +72,56 @@ const useStyles = makeStyles(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(); + 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 ( - {groups.map(group => ( + {buttonGroups.map(group => ( {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} - {entitiesByFilter[item.id]?.length ?? '-'} + + {getFilterCount(item.id) ?? '-'} + ))} @@ -119,3 +159,53 @@ export const CatalogFilter: FC<{ ); }; + +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( + () => ({ + 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, + }; +} diff --git a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx index 65715e04c7..39b3c66013 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx @@ -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 (
{ - const mockErrorApi = new MockErrorApi(); const catalogApi: Partial = { 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 = { + const indentityApi: Partial = { getUserId: () => 'tools@example.com', }; + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + // 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( - - - , - ), - ); - - const items = await findByText(/All Services \(2\)/); - expect(items).toBeInTheDocument(); - }); - it('should filter by owner', async () => { - const { findByText, getByText } = render( - wrapInTestApp( - - - , - ), - ); - fireEvent.click(getByText(/Owned/)); - const items = await findByText(/Owned \(1\)/); - expect(items).toBeInTheDocument(); + const { findByText, getByText } = renderWrapped(); + expect(await findByText(/Owned \(1\)/)).toBeInTheDocument(); + fireEvent.click(getByText(/All/)); + expect(await findByText(/All \(2\)/)).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index ef77cf573f..e59b753363 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -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(); + const [selectedSidebarItem, setSelectedSidebarItem] = useState(); - const YellowStar = withStyles({ - root: { - color: '#f3ba37', - }, - })(Star); + const tabs = useMemo( + () => [ + { + 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( + () => [ + { + 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 ( - { - selectTypeFilter(labeledEntityTypes[index as number].id); - }} + setSelectedTab(label)} /> - - - 👋🏼 - - Welcome to Backstage, we are happy to have you. Start by checking - out our{' '} - - getting started - {' '} - page. - - } - id="catalog_page_welcome_banner" - /> - + + All your software catalog entities
setSelectedSidebarItem(label)} + initiallySelected="owned" />
); }; + +export const CatalogPage = () => ( + + + +); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx b/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx new file mode 100644 index 0000000000..ae1e2a4e5a --- /dev/null +++ b/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx @@ -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(() => { + 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(0); + + // Hold a reference to the callback + const onChangeRef = useRef(); + 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 ; +}; diff --git a/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx b/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx new file mode 100644 index 0000000000..ab486081b1 --- /dev/null +++ b/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx @@ -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 ( + + + 👋🏼 + + Welcome to Backstage, we are happy to have you. Start by checking out + our{' '} + + getting started + {' '} + page. + + } + id="catalog_page_welcome_banner" + /> + ); +}; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index b50da60a50..707213f3bb 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -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(); }); }); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index f62749ee17..b507b96f05 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -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[] = [ { title: 'Name', field: 'metadata.name', @@ -63,16 +68,16 @@ type CatalogTableProps = { titlePreamble: string; loading: boolean; error?: any; - actions?: any; }; -export const CatalogTable: FC = ({ +export const CatalogTable = ({ entities, loading, error, titlePreamble, - actions, -}) => { +}: CatalogTableProps) => { + const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); + if (error) { return (
@@ -83,8 +88,57 @@ export const CatalogTable: FC = ({ ); } + const actions: TableProps['actions'] = [ + (rowData: Entity) => { + const location = findLocationForEntityMeta(rowData.metadata); + return { + icon: () => , + 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: () => , + 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 ? ( + + ) : ( + + ), + tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites', + onClick: () => toggleStarredEntity(rowData), + }; + }, + ]; + return ( - isLoading={loading} columns={columns} options={{ diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx index e6064f8af1..1b966c0bde 100644 --- a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx @@ -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')); }); }); diff --git a/plugins/catalog/src/data/filters.ts b/plugins/catalog/src/data/filters.ts deleted file mode 100644 index 51ed5582f5..0000000000 --- a/plugins/catalog/src/data/filters.ts +++ /dev/null @@ -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 = { - [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]; diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx new file mode 100644 index 0000000000..dd557a4322 --- /dev/null +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -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 ( + + {children} + + ); +}; + +// 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; + }>({}); + const [filterGroupStates, setFilterGroupStates] = useState<{ + [filterGroupId: string]: FilterGroupStates; + }>({}); + const [matchingEntities, setMatchingEntities] = useState([]); + + 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 }, + 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 }, + 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))) ?? []; +} diff --git a/plugins/catalog/src/filter/context.ts b/plugins/catalog/src/filter/context.ts new file mode 100644 index 0000000000..31e9c91b97 --- /dev/null +++ b/plugins/catalog/src/filter/context.ts @@ -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); diff --git a/plugins/catalog/src/filter/index.ts b/plugins/catalog/src/filter/index.ts new file mode 100644 index 0000000000..da73147ef9 --- /dev/null +++ b/plugins/catalog/src/filter/index.ts @@ -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'; diff --git a/plugins/catalog/src/filter/types.ts b/plugins/catalog/src/filter/types.ts new file mode 100644 index 0000000000..ed08b131bf --- /dev/null +++ b/plugins/catalog/src/filter/types.ts @@ -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; diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx new file mode 100644 index 0000000000..60e7d4f412 --- /dev/null +++ b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx @@ -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; + 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 }) => ( + + {children} + + ); + }); + + 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, + }); + }); +}); diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.ts b/plugins/catalog/src/filter/useEntityFilterGroup.ts new file mode 100644 index 0000000000..242238e4f4 --- /dev/null +++ b/plugins/catalog/src/filter/useEntityFilterGroup.ts @@ -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 }; +}; diff --git a/plugins/catalog/src/filter/useFilteredEntities.ts b/plugins/catalog/src/filter/useFilteredEntities.ts new file mode 100644 index 0000000000..256f5247c7 --- /dev/null +++ b/plugins/catalog/src/filter/useFilteredEntities.ts @@ -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, + }; +} diff --git a/plugins/catalog/src/hooks/useEntities.ts b/plugins/catalog/src/hooks/useEntities.ts deleted file mode 100644 index 6509bd9b4b..0000000000 --- a/plugins/catalog/src/hooks/useEntities.ts +++ /dev/null @@ -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; - -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( - 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, - }; -}; diff --git a/plugins/scaffolder-backend/scripts/Dockerfile b/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile similarity index 100% rename from plugins/scaffolder-backend/scripts/Dockerfile rename to plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 9ef290fa40..ca8e957a46 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -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": { diff --git a/plugins/techdocs/src/config.js b/plugins/techdocs/src/config.js new file mode 100644 index 0000000000..296b9fc14c --- /dev/null +++ b/plugins/techdocs/src/config.js @@ -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'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 23bfa3b714..fec2b1cf74 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -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', }); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 5517c0c715..0175462cc3 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -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 = ( - list: Array, - attributeName: string, - ): void => { - Array.from(list).forEach((elem: T) => { - const newUrl = new URL( - elem.getAttribute(attributeName)!, - baseUrl, - ).toString(); - elem.setAttribute(attributeName, newUrl); - }); - }; - - updateDom(Array.from(domParser.images), 'src'); - updateDom( - Array.from(domParser.links), - 'href', - ); - updateDom( - 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 ( <> -

Shadow DOM should be underneath

+
); diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts new file mode 100644 index 0000000000..653f0b2f71 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -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 = ( + list: HTMLCollectionOf | NodeListOf, + 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(dom.querySelectorAll('img'), 'src'); + updateDom(dom.querySelectorAll('script'), 'src'); + updateDom(dom.querySelectorAll('link'), 'href'); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/addEventListener.ts b/plugins/techdocs/src/reader/transformers/addEventListener.ts new file mode 100644 index 0000000000..32b9d6b0b9 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addEventListener.ts @@ -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; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts new file mode 100644 index 0000000000..64c5527fe7 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -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; diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts new file mode 100644 index 0000000000..3f80dbe4e9 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -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 = ( + list: Array, + 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; + }; +}; diff --git a/plugins/techdocs/src/reader/urlParser.test.ts b/plugins/techdocs/src/reader/urlParser.test.ts new file mode 100644 index 0000000000..4322fc42e1 --- /dev/null +++ b/plugins/techdocs/src/reader/urlParser.test.ts @@ -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/', + ); + }); +}); diff --git a/plugins/techdocs/src/reader/urlParser.ts b/plugins/techdocs/src/reader/urlParser.ts new file mode 100644 index 0000000000..b1429b7635 --- /dev/null +++ b/plugins/techdocs/src/reader/urlParser.ts @@ -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(); + } +}