From 984b5acccc48bcc5344822d25fac4450dfead90e Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 12:18:52 +0200 Subject: [PATCH 01/22] feat(mkdocs-reader): initial navigation inside docs --- plugins/techdocs/package.json | 1 + plugins/techdocs/src/config.js | 16 +++++ plugins/techdocs/src/plugin.ts | 2 +- .../techdocs/src/reader/components/Reader.tsx | 62 ++++++++----------- .../techdocs/src/transformers/addBaseUrl.ts | 52 ++++++++++++++++ plugins/techdocs/src/transformers/index.ts | 28 +++++++++ .../src/transformers/rewriteDocLinks.ts | 38 ++++++++++++ 7 files changed, 163 insertions(+), 36 deletions(-) create mode 100644 plugins/techdocs/src/config.js create mode 100644 plugins/techdocs/src/transformers/addBaseUrl.ts create mode 100644 plugins/techdocs/src/transformers/index.ts create mode 100644 plugins/techdocs/src/transformers/rewriteDocLinks.ts diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 9ef290fa40..b84b98c745 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -29,6 +29,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router-dom": "^5.2.0", "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..2baf8b725c --- /dev/null +++ b/plugins/techdocs/src/config.js @@ -0,0 +1,16 @@ +/* + * 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 baseUrl = '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..36816fcc9a 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,6 +17,10 @@ import React from 'react'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; +import transformer, { addBaseUrl, rewriteDocLinks } from '../../transformers'; +import { baseUrl } from '../../config'; +import { Link } from '@backstage/core'; +import { useLocation, useParams } from 'react-router-dom'; const useFetch = (url: string) => { const state = useAsync(async () => { @@ -28,54 +32,42 @@ const useFetch = (url: string) => { return state; }; -const addBaseUrl = (htmlString: string, baseUrl: string): string => { - const domParser = new DOMParser().parseFromString(htmlString, 'text/html'); - - 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; +const normalizeUrl = (path: string) => { + return path.replace(/\/\/index.html$/, '/index.html'); }; 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', + normalizeUrl( + `${baseUrl}${location.pathname.replace('/docs', '')}/index.html`, + ), ); - + // https://techdocs-mock-sites.storage.googleapis.com/mkdocs/user-guide/configuration/custom-themes/index.html 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/', - ); + divElement.shadowRoot.innerHTML = transformer(state.value, [ + addBaseUrl({ + baseUrl, + componentId, + path, + }), + rewriteDocLinks({ + componentId, + }), + ]); } - }, [shadowDomRef, state]); + }, [shadowDomRef, state, componentId, path]); return ( <> -

Shadow DOM should be underneath

+
); diff --git a/plugins/techdocs/src/transformers/addBaseUrl.ts b/plugins/techdocs/src/transformers/addBaseUrl.ts new file mode 100644 index 0000000000..674e3ab33b --- /dev/null +++ b/plugins/techdocs/src/transformers/addBaseUrl.ts @@ -0,0 +1,52 @@ +/* + * 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. + */ +type AddBaseUrlOptions = { + baseUrl: string; + componentId: string; + path: string; +}; + +export const addBaseUrl = ({ + baseUrl, + componentId, + path, +}: AddBaseUrlOptions) => { + return (dom: Document): Document => { + const updateDom = ( + list: Array, + attributeName: string, + ): void => { + Array.from(list) + .filter(elem => !!elem.getAttribute(attributeName)) + .forEach((elem: T) => { + const newUrl = new URL( + elem.getAttribute(attributeName)!, + `${baseUrl}/${componentId}/${path}`, + ).toString(); + elem.setAttribute(attributeName, newUrl); + }); + }; + + updateDom(Array.from(dom.images), 'src'); + updateDom(Array.from(dom.scripts), 'src'); + updateDom( + Array.from(dom.querySelectorAll('link')), + 'href', + ); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/transformers/index.ts b/plugins/techdocs/src/transformers/index.ts new file mode 100644 index 0000000000..b530840d08 --- /dev/null +++ b/plugins/techdocs/src/transformers/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 * from './addBaseUrl'; +export * from './rewriteDocLinks'; + +type Transformer = (dom: Document) => Document; + +export default (html: string, transformers: Transformer[]): string => { + const dom = new DOMParser().parseFromString(html, 'text/html'); + + transformers.forEach(transformer => transformer(dom)); + + return dom.body.parentElement?.outerHTML ?? ''; +}; diff --git a/plugins/techdocs/src/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/transformers/rewriteDocLinks.ts new file mode 100644 index 0000000000..459b7eda3c --- /dev/null +++ b/plugins/techdocs/src/transformers/rewriteDocLinks.ts @@ -0,0 +1,38 @@ +/* + * 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. + */ +type AddBaseUrlOptions = { + componentId: string; +}; + +export const rewriteDocLinks = ({ componentId }: AddBaseUrlOptions) => { + return (dom: Document): Document => { + const updateDom = ( + list: Array, + attributeName: string, + ): void => { + Array.from(list) + .filter(elem => !!elem.getAttribute(attributeName)) + .forEach((elem: T) => { + const newUrl = `${componentId}/${elem.getAttribute(attributeName)}`; + elem.setAttribute(attributeName, newUrl); + }); + }; + + updateDom(Array.from(dom.getElementsByTagName('a')), 'href'); + + return dom; + }; +}; From 879bc21893f076a1386613dae6da10f4751f4b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 26 Jun 2020 09:41:28 +0200 Subject: [PATCH 02/22] feat(test-utils): mock storage api --- packages/test-utils/package.json | 3 +- .../apis/StorageApi/MockStorageApi.test.ts | 142 ++++++++++++++++++ .../apis/StorageApi/MockStorageApi.ts | 90 +++++++++++ .../src/testUtils/apis/StorageApi/index.ts | 18 +++ .../test-utils/src/testUtils/apis/index.ts | 1 + .../src/testUtils/mockApiRegistry.ts | 3 +- 6 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts create mode 100644 packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts create mode 100644 packages/test-utils/src/testUtils/apis/StorageApi/index.ts 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; } From d1f9c51d11d3379bc9563ed9db228039cdeaa5aa Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 13:27:37 +0200 Subject: [PATCH 03/22] fix react-router version --- plugins/techdocs/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index b84b98c745..ca8e957a46 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -29,7 +29,8 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.2.0", + "react-router": "^6.0.0-alpha.5", + "react-router-dom": "^6.0.0-alpha.5", "react-use": "^14.2.0" }, "devDependencies": { From 674d14241870318b8178602b09f01c8f98d64ec5 Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 14:53:19 +0200 Subject: [PATCH 04/22] Update URLS better --- plugins/techdocs/src/config.js | 3 +- .../techdocs/src/reader/components/Reader.tsx | 20 +++++++----- .../{ => reader}/transformers/addBaseUrl.ts | 13 +++++--- .../src/{ => reader}/transformers/index.ts | 7 +++-- .../transformers/rewriteDocLinks.ts | 20 +++++++----- plugins/techdocs/src/reader/urlParser.ts | 31 +++++++++++++++++++ 6 files changed, 71 insertions(+), 23 deletions(-) rename plugins/techdocs/src/{ => reader}/transformers/addBaseUrl.ts (87%) rename plugins/techdocs/src/{ => reader}/transformers/index.ts (85%) rename plugins/techdocs/src/{ => reader}/transformers/rewriteDocLinks.ts (69%) create mode 100644 plugins/techdocs/src/reader/urlParser.ts diff --git a/plugins/techdocs/src/config.js b/plugins/techdocs/src/config.js index 2baf8b725c..296b9fc14c 100644 --- a/plugins/techdocs/src/config.js +++ b/plugins/techdocs/src/config.js @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export const baseUrl = 'https://techdocs-mock-sites.storage.googleapis.com'; +export const docStorageURL = + 'https://techdocs-mock-sites.storage.googleapis.com'; diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 36816fcc9a..0a0bb88421 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,8 +17,8 @@ import React from 'react'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; -import transformer, { addBaseUrl, rewriteDocLinks } from '../../transformers'; -import { baseUrl } from '../../config'; +import transformer, { addBaseUrl, rewriteDocLinks } from '../transformers'; +import { docStorageURL } from '../../config'; import { Link } from '@backstage/core'; import { useLocation, useParams } from 'react-router-dom'; @@ -42,16 +42,16 @@ export const Reader = () => { const shadowDomRef = useShadowDom(); const state = useFetch( normalizeUrl( - `${baseUrl}${location.pathname.replace('/docs', '')}/index.html`, + `${docStorageURL}${location.pathname.replace('/docs', '')}/index.html`, ), ); - // https://techdocs-mock-sites.storage.googleapis.com/mkdocs/user-guide/configuration/custom-themes/index.html + React.useEffect(() => { const divElement = shadowDomRef.current; if (divElement?.shadowRoot && state.value) { - divElement.shadowRoot.innerHTML = transformer(state.value, [ + const transformedElement = transformer(state.value, [ addBaseUrl({ - baseUrl, + docStorageURL, componentId, path, }), @@ -59,14 +59,18 @@ export const Reader = () => { componentId, }), ]); + + divElement.shadowRoot.innerHTML = ''; + if (transformedElement) + divElement.shadowRoot.appendChild(transformedElement); } }, [shadowDomRef, state, componentId, path]); return ( <>
diff --git a/plugins/techdocs/src/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts similarity index 87% rename from plugins/techdocs/src/transformers/addBaseUrl.ts rename to plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 674e3ab33b..e07a41fc03 100644 --- a/plugins/techdocs/src/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -13,14 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import URLParser from '../urlParser'; + type AddBaseUrlOptions = { - baseUrl: string; + docStorageURL: string; componentId: string; path: string; }; export const addBaseUrl = ({ - baseUrl, + docStorageURL, componentId, path, }: AddBaseUrlOptions) => { @@ -32,10 +35,10 @@ export const addBaseUrl = ({ Array.from(list) .filter(elem => !!elem.getAttribute(attributeName)) .forEach((elem: T) => { - const newUrl = new URL( + const newUrl = new URLParser( + `${docStorageURL}/${componentId}/${path}`, elem.getAttribute(attributeName)!, - `${baseUrl}/${componentId}/${path}`, - ).toString(); + ).parse(); elem.setAttribute(attributeName, newUrl); }); }; diff --git a/plugins/techdocs/src/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts similarity index 85% rename from plugins/techdocs/src/transformers/index.ts rename to plugins/techdocs/src/reader/transformers/index.ts index b530840d08..008c86e261 100644 --- a/plugins/techdocs/src/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -19,10 +19,13 @@ export * from './rewriteDocLinks'; type Transformer = (dom: Document) => Document; -export default (html: string, transformers: Transformer[]): string => { +export default ( + html: string, + transformers: Transformer[], +): HTMLElement | undefined => { const dom = new DOMParser().parseFromString(html, 'text/html'); transformers.forEach(transformer => transformer(dom)); - return dom.body.parentElement?.outerHTML ?? ''; + return dom.body.parentElement ?? undefined; }; diff --git a/plugins/techdocs/src/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts similarity index 69% rename from plugins/techdocs/src/transformers/rewriteDocLinks.ts rename to plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index 459b7eda3c..373db51abd 100644 --- a/plugins/techdocs/src/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -13,21 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -type AddBaseUrlOptions = { - componentId: string; -}; -export const rewriteDocLinks = ({ componentId }: AddBaseUrlOptions) => { +import URLParser from '../urlParser'; + +type AddBaseUrlOptions = {}; + +export const rewriteDocLinks = ({}: AddBaseUrlOptions) => { return (dom: Document): Document => { const updateDom = ( list: Array, attributeName: string, ): void => { Array.from(list) - .filter(elem => !!elem.getAttribute(attributeName)) + .filter(elem => elem.hasAttribute(attributeName)) .forEach((elem: T) => { - const newUrl = `${componentId}/${elem.getAttribute(attributeName)}`; - elem.setAttribute(attributeName, newUrl); + elem.setAttribute( + attributeName, + new URLParser( + window.location.href, + elem.getAttribute(attributeName)!, + ).parse(), + ); }); }; 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(); + } +} From d917f72280589547ae026634f78dc52849dc9520 Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 14:59:50 +0200 Subject: [PATCH 05/22] Enforce trailing slash --- plugins/techdocs/src/reader/components/Reader.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 0a0bb88421..d4e84aa8d5 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -21,6 +21,7 @@ import transformer, { addBaseUrl, rewriteDocLinks } from '../transformers'; import { docStorageURL } from '../../config'; import { Link } from '@backstage/core'; import { useLocation, useParams } from 'react-router-dom'; +import URLParser from '../urlParser'; const useFetch = (url: string) => { const state = useAsync(async () => { @@ -36,6 +37,17 @@ const normalizeUrl = (path: string) => { return path.replace(/\/\/index.html$/, '/index.html'); }; +const useEnforcedTrailingSlash = (): void => { + React.useEffect(() => { + const actualUrl = window.location.href; + const expectedUrl = new URLParser(window.location.href, '.').parse(); + + if (actualUrl !== expectedUrl) { + window.history.replaceState({}, document.title, expectedUrl); + } + }, []); +}; + export const Reader = () => { const location = useLocation(); const { componentId, '*': path } = useParams(); @@ -46,6 +58,8 @@ export const Reader = () => { ), ); + useEnforcedTrailingSlash(); + React.useEffect(() => { const divElement = shadowDomRef.current; if (divElement?.shadowRoot && state.value) { From 14bbaf099ebb33a62c0a093f9e93691f382e208f Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 15:09:51 +0200 Subject: [PATCH 06/22] Add tests for URLParser --- plugins/techdocs/src/reader/urlParser.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 plugins/techdocs/src/reader/urlParser.test.ts 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/', + ); + }); +}); From afcf01d6f4dbcb1694c503cd9fedb13d0f620d8b Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 16:34:30 +0200 Subject: [PATCH 07/22] Cleanups and better navigation between docs --- .../techdocs/src/reader/components/Reader.tsx | 33 +++++++------ .../src/reader/transformers/addBaseUrl.ts | 16 +++---- .../reader/transformers/addEventListener.ts | 41 +++++++++++++++++ .../techdocs/src/reader/transformers/index.ts | 46 ++++++++++++++++--- .../reader/transformers/rewriteDocLinks.ts | 5 +- 5 files changed, 110 insertions(+), 31 deletions(-) create mode 100644 plugins/techdocs/src/reader/transformers/addEventListener.ts diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index d4e84aa8d5..0175462cc3 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,10 +17,14 @@ import React from 'react'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; -import transformer, { addBaseUrl, rewriteDocLinks } from '../transformers'; +import transformer, { + addBaseUrl, + rewriteDocLinks, + addEventListener, +} from '../transformers'; import { docStorageURL } from '../../config'; import { Link } from '@backstage/core'; -import { useLocation, useParams } from 'react-router-dom'; +import { useLocation, useParams, useNavigate } from 'react-router-dom'; import URLParser from '../urlParser'; const useFetch = (url: string) => { @@ -33,10 +37,6 @@ const useFetch = (url: string) => { return state; }; -const normalizeUrl = (path: string) => { - return path.replace(/\/\/index.html$/, '/index.html'); -}; - const useEnforcedTrailingSlash = (): void => { React.useEffect(() => { const actualUrl = window.location.href; @@ -52,11 +52,12 @@ export const Reader = () => { const location = useLocation(); const { componentId, '*': path } = useParams(); const shadowDomRef = useShadowDom(); - const state = useFetch( - normalizeUrl( - `${docStorageURL}${location.pathname.replace('/docs', '')}/index.html`, - ), - ); + const navigate = useNavigate(); + const normalizedUrl = new URLParser( + `${docStorageURL}${location.pathname.replace('/docs', '')}`, + '.', + ).parse(); + const state = useFetch(`${normalizedUrl}index.html`); useEnforcedTrailingSlash(); @@ -75,10 +76,16 @@ export const Reader = () => { ]); divElement.shadowRoot.innerHTML = ''; - if (transformedElement) + if (transformedElement) { divElement.shadowRoot.appendChild(transformedElement); + transformer(divElement.shadowRoot.children[0], [ + addEventListener({ + onClick: navigate, + }), + ]); + } } - }, [shadowDomRef, state, componentId, path]); + }, [shadowDomRef, state, componentId, path, navigate]); return ( <> diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index e07a41fc03..653f0b2f71 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -15,6 +15,7 @@ */ import URLParser from '../urlParser'; +import type { Transformer } from './index'; type AddBaseUrlOptions = { docStorageURL: string; @@ -26,10 +27,10 @@ export const addBaseUrl = ({ docStorageURL, componentId, path, -}: AddBaseUrlOptions) => { - return (dom: Document): Document => { +}: AddBaseUrlOptions): Transformer => { + return dom => { const updateDom = ( - list: Array, + list: HTMLCollectionOf | NodeListOf, attributeName: string, ): void => { Array.from(list) @@ -43,12 +44,9 @@ export const addBaseUrl = ({ }); }; - updateDom(Array.from(dom.images), 'src'); - updateDom(Array.from(dom.scripts), 'src'); - updateDom( - Array.from(dom.querySelectorAll('link')), - 'href', - ); + 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 index 008c86e261..74c9eb6549 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -16,16 +16,48 @@ export * from './addBaseUrl'; export * from './rewriteDocLinks'; +export * from './addEventListener'; -type Transformer = (dom: Document) => Document; +export type Transformer = (dom: Element) => Element; -export default ( - html: string, +function transform( + html: string | Element, transformers: Transformer[], -): HTMLElement | undefined => { - const dom = new DOMParser().parseFromString(html, 'text/html'); +): 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.body.parentElement ?? undefined; -}; + return dom; +} + +// function transform( +// html: string, +// transformers: Transformer[], +// ): HTMLElement { +// const dom = new DOMParser().parseFromString(html, 'text/html'); + +// transformers.forEach(transformer => transformer(dom)); + +// return dom.documentElement; +// }; + +// function transform( +// html: HTMLElement, +// transformers: Transformer[], +// ): HTMLElement { + +// transformers.forEach(transformer => transformer(element)); + +// return html; +// }; + +export default transform; diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index 373db51abd..3f80dbe4e9 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -15,11 +15,12 @@ */ import URLParser from '../urlParser'; +import type { Transformer } from './index'; type AddBaseUrlOptions = {}; -export const rewriteDocLinks = ({}: AddBaseUrlOptions) => { - return (dom: Document): Document => { +export const rewriteDocLinks = ({}: AddBaseUrlOptions): Transformer => { + return dom => { const updateDom = ( list: Array, attributeName: string, From 985c1657444ad04ffec8b3a2de5f3da4ba6e3636 Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 16:36:14 +0200 Subject: [PATCH 08/22] Removed commented code --- .../techdocs/src/reader/transformers/index.ts | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index 74c9eb6549..64c5527fe7 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -39,25 +39,4 @@ function transform( return dom; } -// function transform( -// html: string, -// transformers: Transformer[], -// ): HTMLElement { -// const dom = new DOMParser().parseFromString(html, 'text/html'); - -// transformers.forEach(transformer => transformer(dom)); - -// return dom.documentElement; -// }; - -// function transform( -// html: HTMLElement, -// transformers: Transformer[], -// ): HTMLElement { - -// transformers.forEach(transformer => transformer(element)); - -// return html; -// }; - export default transform; From ad9d74ff240c934ce46a474ae3f440d5f5fa493b Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 26 Jun 2020 18:14:41 +0200 Subject: [PATCH 09/22] chore(scaffolder): Moving the cookiecutter dockerfile to more semantic name --- .../scripts/{Dockerfile => Cookiecutter.dockerfile} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugins/scaffolder-backend/scripts/{Dockerfile => Cookiecutter.dockerfile} (100%) 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 From 5d7679d8becb363f40556f2a6950f5fbee317e3a Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Mon, 22 Jun 2020 16:32:05 +0200 Subject: [PATCH 10/22] Initial useEntityFilterGroup implementation --- .../CatalogFilter/CatalogFilter.tsx | 5 +- .../components/CatalogPage/CatalogPage.tsx | 2 +- .../catalog/src/hooks/useEntities.test.tsx | 105 ++++++++++++++++++ plugins/catalog/src/hooks/useEntities.ts | 85 +++++++++++++- 4 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 plugins/catalog/src/hooks/useEntities.test.tsx diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index 61a83fccd8..e40f4806ed 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -20,6 +20,7 @@ import { List, ListItemIcon, ListItemText, + ListItemSecondaryAction, MenuItem, Typography, Theme, @@ -109,7 +110,9 @@ export const CatalogFilter: FC<{ {item.label} - {entitiesByFilter[item.id]?.length ?? '-'} + + {entitiesByFilter[item.id]?.length ?? '-'} + ))} diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index ef77cf573f..9329eb9317 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -162,7 +162,7 @@ export const CatalogPage: FC<{}> = () => { color="primary" to={scaffolderRootRoute.path} > - Create Service + Create Component All your software catalog entities diff --git a/plugins/catalog/src/hooks/useEntities.test.tsx b/plugins/catalog/src/hooks/useEntities.test.tsx new file mode 100644 index 0000000000..c8176f3a6c --- /dev/null +++ b/plugins/catalog/src/hooks/useEntities.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useEntityFilterGroup } from './useEntities'; + +describe('useEntitiesHooks', () => { + const testEntities = [ + { name: 'test1', type: 'type1' }, + { name: 'test2', type: 'type2' }, + { name: 'test3', type: 'type3' }, + { name: 'test4', type: 'type2' }, + { name: 'test5', type: 'type2' }, + ]; + + type TestEntitiy = { + name: string; + type: string; + }; + + const testFilterFunctions = { + type1: { + filterFunction: (entity: TestEntitiy) => entity.type === 'type1', + isSelected: false, + }, + type2: { + filterFunction: (entity: TestEntitiy) => entity.type === 'type2', + isSelected: false, + }, + type3: { + filterFunction: (entity: TestEntitiy) => entity.type === 'type3', + isSelected: false, + }, + }; + + it('should calculate count', async () => { + const { result } = renderHook(() => + useEntityFilterGroup(testEntities, testFilterFunctions), + ); + + expect(result.current.states.type1.count).toBe(1); + expect(result.current.states.type2.count).toBe(3); + expect(result.current.states.type3.count).toBe(1); + }); + + it('should set the isSelected flag properly', () => { + const { result } = renderHook(() => + useEntityFilterGroup(testEntities, testFilterFunctions), + ); + + expect(result.current.states.type1.isSelected).toBeFalsy(); + expect(result.current.states.type2.isSelected).toBeFalsy(); + expect(result.current.states.type3.isSelected).toBeFalsy(); + + act(() => { + result.current.selectItems(['type1']); + }); + + expect(result.current.states.type1.isSelected).toBeTruthy(); + expect(result.current.states.type2.isSelected).toBeFalsy(); + expect(result.current.states.type3.isSelected).toBeFalsy(); + }); + + it('should filter entities', () => { + const { result } = renderHook(() => + useEntityFilterGroup(testEntities, testFilterFunctions), + ); + + act(() => { + result.current.selectItems(['type1']); + }); + expect(result.current.filteredItems).toEqual([ + { name: 'test1', type: 'type1' }, + ]); + + act(() => { + result.current.selectItems(['type2']); + }); + expect(result.current.filteredItems).toEqual([ + { name: 'test2', type: 'type2' }, + { name: 'test4', type: 'type2' }, + { name: 'test5', type: 'type2' }, + ]); + + act(() => { + result.current.selectItems(['type3', 'type1']); + }); + expect(result.current.filteredItems).toEqual([ + { name: 'test1', type: 'type1' }, + { name: 'test3', type: 'type3' }, + ]); + }); +}); diff --git a/plugins/catalog/src/hooks/useEntities.ts b/plugins/catalog/src/hooks/useEntities.ts index 6509bd9b4b..c48e3af026 100644 --- a/plugins/catalog/src/hooks/useEntities.ts +++ b/plugins/catalog/src/hooks/useEntities.ts @@ -40,6 +40,88 @@ type UseEntities = { selectTypeFilter: (id: string) => void; }; +type EntityFilterGroupOutput = { + selectItems: (items: string[]) => void; + filteredItems: T[]; + states: OutputState; +}; + +type OutputState = { [key: string]: { isSelected: boolean; count: number } }; + +type FilterDefinition = { + [key: string]: { + isSelected: boolean; + filterFunction: (entity: T) => boolean; + }; +}; + +export const useEntityFilterGroup = ( + entities: T[], + filterFunctions: FilterDefinition, +): EntityFilterGroupOutput => { + const [filterFuncs, setFilterFuncs] = useState>( + filterFunctions, + ); + + // and + // Object.entries(filterFuncs).filter(([_, {isSelected}]) => isSelected).map(([_, {filterFunction}]) => filterFunction).reduce((acc, func) => (acc.filter(func)), entities) + + return { + selectItems: (functionNames: Array) => { + const selectedFilterFunctions = Object.fromEntries( + Object.entries(filterFunctions).map(([key, { filterFunction }]) => [ + key, + { isSelected: functionNames.includes(key), filterFunction }, + ]), + ); + setFilterFuncs(selectedFilterFunctions); + }, + filteredItems: entities.filter(entity => + Object.entries(filterFuncs) + .filter(([_, { isSelected }]) => isSelected) + .map(([_, { filterFunction }]) => filterFunction) + .map(filter => filter(entity)) + .some(v => v === true), + ), + states: Object.keys(filterFuncs).reduce( + (acc, val) => ({ + ...acc, + [val]: { + ...filterFuncs[val], + count: entities.filter(filterFuncs[val].filterFunction).length, + }, + }), + {} as OutputState, + ), + }; +}; + +// const MyFilterGroup = () => { +// const { selectedItems, selectItems, counts } = useEntityFilterGroup( +// 'lifecycle', +// { +// production: e => e.spec?.lifecyle === 'production', +// }, +// ); + +// return ( +// +// selectItem('production')} +// > +// Production ({counts.production}) +// +// +// ); +// }; + +export const useUser = () => { + const indentityApi = useApi(identityApiRef); + const userId = indentityApi.getUserId(); + return { userId }; +}; + export const useEntities = (): UseEntities => { const [selectedFilter, setSelectedFilter] = useState< EntityGroup | undefined @@ -51,8 +133,7 @@ export const useEntities = (): UseEntities => { async () => catalogApi.getEntities(), ); - const indentityApi = useApi(identityApiRef); - const userId = indentityApi.getUserId(); + const { userId } = useUser(); const [selectedTypeFilter, selectTypeFilter] = useState( labeledEntityTypes[0].id, From f895e4b4e33a329f1655aaf716143b773daa6f5c Mon Sep 17 00:00:00 2001 From: Nikita Nek Dudnik Date: Tue, 23 Jun 2020 22:23:25 +0200 Subject: [PATCH 11/22] feat(catalog): implement useEntityFilterGroup hook --- .../catalog/src/hooks/useEntities.test.tsx | 105 ----- plugins/catalog/src/hooks/useEntities.ts | 184 -------- plugins/catalog/src/hooks/useEntities.tsx | 404 ++++++++++++++++++ .../src/hooks/useEntityFilterGroup.test.tsx | 114 +++++ .../src/hooks/useEntityFilterGroup.tsx | 273 ++++++++++++ 5 files changed, 791 insertions(+), 289 deletions(-) delete mode 100644 plugins/catalog/src/hooks/useEntities.test.tsx delete mode 100644 plugins/catalog/src/hooks/useEntities.ts create mode 100644 plugins/catalog/src/hooks/useEntities.tsx create mode 100644 plugins/catalog/src/hooks/useEntityFilterGroup.test.tsx create mode 100644 plugins/catalog/src/hooks/useEntityFilterGroup.tsx diff --git a/plugins/catalog/src/hooks/useEntities.test.tsx b/plugins/catalog/src/hooks/useEntities.test.tsx deleted file mode 100644 index c8176f3a6c..0000000000 --- a/plugins/catalog/src/hooks/useEntities.test.tsx +++ /dev/null @@ -1,105 +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 { renderHook, act } from '@testing-library/react-hooks'; -import { useEntityFilterGroup } from './useEntities'; - -describe('useEntitiesHooks', () => { - const testEntities = [ - { name: 'test1', type: 'type1' }, - { name: 'test2', type: 'type2' }, - { name: 'test3', type: 'type3' }, - { name: 'test4', type: 'type2' }, - { name: 'test5', type: 'type2' }, - ]; - - type TestEntitiy = { - name: string; - type: string; - }; - - const testFilterFunctions = { - type1: { - filterFunction: (entity: TestEntitiy) => entity.type === 'type1', - isSelected: false, - }, - type2: { - filterFunction: (entity: TestEntitiy) => entity.type === 'type2', - isSelected: false, - }, - type3: { - filterFunction: (entity: TestEntitiy) => entity.type === 'type3', - isSelected: false, - }, - }; - - it('should calculate count', async () => { - const { result } = renderHook(() => - useEntityFilterGroup(testEntities, testFilterFunctions), - ); - - expect(result.current.states.type1.count).toBe(1); - expect(result.current.states.type2.count).toBe(3); - expect(result.current.states.type3.count).toBe(1); - }); - - it('should set the isSelected flag properly', () => { - const { result } = renderHook(() => - useEntityFilterGroup(testEntities, testFilterFunctions), - ); - - expect(result.current.states.type1.isSelected).toBeFalsy(); - expect(result.current.states.type2.isSelected).toBeFalsy(); - expect(result.current.states.type3.isSelected).toBeFalsy(); - - act(() => { - result.current.selectItems(['type1']); - }); - - expect(result.current.states.type1.isSelected).toBeTruthy(); - expect(result.current.states.type2.isSelected).toBeFalsy(); - expect(result.current.states.type3.isSelected).toBeFalsy(); - }); - - it('should filter entities', () => { - const { result } = renderHook(() => - useEntityFilterGroup(testEntities, testFilterFunctions), - ); - - act(() => { - result.current.selectItems(['type1']); - }); - expect(result.current.filteredItems).toEqual([ - { name: 'test1', type: 'type1' }, - ]); - - act(() => { - result.current.selectItems(['type2']); - }); - expect(result.current.filteredItems).toEqual([ - { name: 'test2', type: 'type2' }, - { name: 'test4', type: 'type2' }, - { name: 'test5', type: 'type2' }, - ]); - - act(() => { - result.current.selectItems(['type3', 'type1']); - }); - expect(result.current.filteredItems).toEqual([ - { name: 'test1', type: 'type1' }, - { name: 'test3', type: 'type3' }, - ]); - }); -}); diff --git a/plugins/catalog/src/hooks/useEntities.ts b/plugins/catalog/src/hooks/useEntities.ts deleted file mode 100644 index c48e3af026..0000000000 --- a/plugins/catalog/src/hooks/useEntities.ts +++ /dev/null @@ -1,184 +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; -}; - -type EntityFilterGroupOutput = { - selectItems: (items: string[]) => void; - filteredItems: T[]; - states: OutputState; -}; - -type OutputState = { [key: string]: { isSelected: boolean; count: number } }; - -type FilterDefinition = { - [key: string]: { - isSelected: boolean; - filterFunction: (entity: T) => boolean; - }; -}; - -export const useEntityFilterGroup = ( - entities: T[], - filterFunctions: FilterDefinition, -): EntityFilterGroupOutput => { - const [filterFuncs, setFilterFuncs] = useState>( - filterFunctions, - ); - - // and - // Object.entries(filterFuncs).filter(([_, {isSelected}]) => isSelected).map(([_, {filterFunction}]) => filterFunction).reduce((acc, func) => (acc.filter(func)), entities) - - return { - selectItems: (functionNames: Array) => { - const selectedFilterFunctions = Object.fromEntries( - Object.entries(filterFunctions).map(([key, { filterFunction }]) => [ - key, - { isSelected: functionNames.includes(key), filterFunction }, - ]), - ); - setFilterFuncs(selectedFilterFunctions); - }, - filteredItems: entities.filter(entity => - Object.entries(filterFuncs) - .filter(([_, { isSelected }]) => isSelected) - .map(([_, { filterFunction }]) => filterFunction) - .map(filter => filter(entity)) - .some(v => v === true), - ), - states: Object.keys(filterFuncs).reduce( - (acc, val) => ({ - ...acc, - [val]: { - ...filterFuncs[val], - count: entities.filter(filterFuncs[val].filterFunction).length, - }, - }), - {} as OutputState, - ), - }; -}; - -// const MyFilterGroup = () => { -// const { selectedItems, selectItems, counts } = useEntityFilterGroup( -// 'lifecycle', -// { -// production: e => e.spec?.lifecyle === 'production', -// }, -// ); - -// return ( -// -// selectItem('production')} -// > -// Production ({counts.production}) -// -// -// ); -// }; - -export const useUser = () => { - const indentityApi = useApi(identityApiRef); - const userId = indentityApi.getUserId(); - return { userId }; -}; - -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 { userId } = useUser(); - - 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/catalog/src/hooks/useEntities.tsx b/plugins/catalog/src/hooks/useEntities.tsx new file mode 100644 index 0000000000..825a30c09d --- /dev/null +++ b/plugins/catalog/src/hooks/useEntities.tsx @@ -0,0 +1,404 @@ +/* + * 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 { + 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'; +import React, { + createContext, + useState, + useEffect, + useCallback, + useMemo, +} from 'react'; + +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 type FilterGroup = { + filters: { + [key: string]: (entity: Entity) => boolean; + }; +}; + +export type FilterGroupState = { + filters: { + [key: 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; + +export type FilterGroupsContext = { + register: (filterGroupId: string, filterGroup: FilterGroup) => void; + unregister: (filterGroupId: string) => void; + setSelectedFilters: (filterGroupId: string, filters: string[]) => void; + filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; + matchingEntities: Entity[]; +}; + +/** + * The context that maintains shared state for all visible filter groups. + */ +export const filterGroupsContext = createContext( + {} as FilterGroupsContext, +); + +/** + * Implementation of the shared filter groups state. + */ +export const EntityFilterGroupsProvider = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const catalogApi = useApi(catalogApiRef); + const { + data: entities, + error, + } = useStaleWhileRevalidate('catalog/getEntities', async () => + catalogApi.getEntities(), + ); + + const [filterGroups, setFilterGroups] = useState<{ + [filterGroupId: string]: FilterGroup; + }>({}); + const [filterGroupStates, setFilterGroupStates] = useState<{ + [filterGroupId: string]: FilterGroupStates; + }>({}); + const [selectedFilterKeys, setSelectedFilterKeys] = useState>( + new Set(), + ); + const [matchingEntities, setMatchingEntities] = useState([]); + + const buildMatchingEntities = useCallback( + (excludeFilterGroupId?: string): Entity[] => { + // Build one filter fn per filter group + const allFilters: ((entity: Entity) => boolean)[] = []; + 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: ((entity: Entity) => boolean)[] = []; + for (const [filterId, filterFn] of Object.entries( + filterGroup.filters, + )) { + if (selectedFilterKeys.has(`${filterGroupId}.${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))) ?? [] + ); + }, + [entities?.filter, filterGroups, selectedFilterKeys], + ); + const buildStates = useCallback((): { + [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 || !filterGroups.length) { + 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(filterGroupId); + const groupState: FilterGroupState = { filters: {} }; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + const isSelected = selectedFilterKeys.has( + `${filterGroupId}.${filterId}`, + ); + const matchCount = otherMatchingEntities.filter(entity => + filterFn(entity), + ).length; + groupState.filters[filterId] = { isSelected, matchCount }; + } + result[filterGroupId] = { type: 'ready', state: groupState }; + } + + return result; + }, [ + buildMatchingEntities, + entities, + error, + filterGroups, + selectedFilterKeys, + ]); + + useEffect(() => { + setFilterGroupStates(buildStates()); + setMatchingEntities(buildMatchingEntities()); + }, [ + entities, + error, + filterGroups, + selectedFilterKeys, + buildStates, + buildMatchingEntities, + ]); + + const register = useCallback( + (filterGroupId: string, filterGroup: FilterGroup) => { + setFilterGroups(oldGroups => ({ + ...oldGroups, + [filterGroupId]: filterGroup, + })); + }, + [], + ); + + const unregister = useCallback((filterGroupId: string) => { + setFilterGroups(oldGroups => { + const copy = { ...oldGroups }; + delete copy[filterGroupId]; + return copy; + }); + setFilterGroupStates(oldStates => { + const copy = { ...oldStates }; + delete copy[filterGroupId]; + return copy; + }); + }, []); + + const setSelectedFilters = useCallback( + (filterGroupId: string, filters: string[]) => { + const result = new Set(); + for (const key of selectedFilterKeys) { + if (!key.startsWith(`${filterGroupId}.`)) { + result.add(key); + } + } + for (const key of filters) { + result.add(`${filterGroupId}.${key}`); + } + setSelectedFilterKeys(result); + }, + [selectedFilterKeys], + ); + + const state: FilterGroupsContext = { + register, + unregister, + setSelectedFilters, + filterGroupStates, + matchingEntities, + }; + + return ( + + {children} + + ); +}; + +/** + * Hook that exposes the relevant data and operations for a single filter + * group. + */ +/* +export const useEntityFilterGroup = ( + filterGroupId: string, + filterGroup: FilterGroup, +): EntityFilterGroupOutput => { + const groupsContext = useContext(filterGroupsContext); + if (!groupsContext) { + throw new Error('You must be inside an EntityFilterGroupsProvider'); + } + + useEffect(() => { + groupsContext.register(filterGroupId, filterGroup); + return () => groupsContext.unregister(filterGroupId); + }, []); + + const state = groupsContext.getFilterGroup(filterGroupId); + if (!state) { + return null; + } + + const {} = state; + + const [filterFuncs, setFilterFuncs] = useState>( + filterFunctions, + ); + + // and + // Object.entries(filterFuncs).filter(([_, {isSelected}]) => isSelected).map(([_, {filterFunction}]) => filterFunction).reduce((acc, func) => (acc.filter(func)), entities) + + return { + selectItems: (functionNames: Array) => { + const selectedFilterFunctions = Object.fromEntries( + Object.entries(filterFunctions).map(([key, { filterFunction }]) => [ + key, + { isSelected: functionNames.includes(key), filterFunction }, + ]), + ); + setFilterFuncs(selectedFilterFunctions); + }, + filteredItems: entities.filter(entity => + Object.entries(filterFuncs) + .filter(([_, { isSelected }]) => isSelected) + .map(([_, { filterFunction }]) => filterFunction) + .map(filter => filter(entity)) + .some(v => v === true), + ), + states: Object.keys(filterFuncs).reduce( + (acc, val) => ({ + ...acc, + [val]: { + ...filterFuncs[val], + count: entities.filter(filterFuncs[val].filterFunction).length, + }, + }), + {} as OutputState, + ), + }; +}; +*/ + +export const useUser = () => { + const indentityApi = useApi(identityApiRef); + const userId = indentityApi.getUserId(); + return { userId }; +}; + +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 { userId } = useUser(); + + 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/catalog/src/hooks/useEntityFilterGroup.test.tsx b/plugins/catalog/src/hooks/useEntityFilterGroup.test.tsx new file mode 100644 index 0000000000..287fa6d11c --- /dev/null +++ b/plugins/catalog/src/hooks/useEntityFilterGroup.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { + EntityFilterGroupsProvider, + useEntityFilterGroup, + FilterGroupStatesReady, +} from './useEntityFilterGroup'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef } from '..'; + +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(), + }; + wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + }); + + it('works for an empty set of filters', async () => { + catalogApi.getEntities.mockResolvedValue([]); + const { result, wait } = renderHook( + () => useEntityFilterGroup('g1', { filters: {} }), + { 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 { result, wait } = renderHook( + () => + useEntityFilterGroup('g1', { + filters: { + f1: e => e.metadata.name === 'n', + f2: e => e.metadata.name !== 'n', + }, + }), + { 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.selectItems(['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.selectItems(['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/hooks/useEntityFilterGroup.tsx b/plugins/catalog/src/hooks/useEntityFilterGroup.tsx new file mode 100644 index 0000000000..455795baf4 --- /dev/null +++ b/plugins/catalog/src/hooks/useEntityFilterGroup.tsx @@ -0,0 +1,273 @@ +/* + * 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 { EntityGroup } from '../data/filters'; +import { useApi } from '@backstage/core'; +import { catalogApiRef } from '..'; +import { Entity } from '@backstage/catalog-model'; +import React, { + createContext, + useState, + useEffect, + useCallback, + useContext, +} from 'react'; +import { useAsync } from 'react-use'; + +export type EntitiesByFilter = Record; + +export type FilterGroup = { + filters: { + [key: string]: (entity: Entity) => boolean; + }; +}; + +export type FilterGroupState = { + filters: { + [key: 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; + +export type FilterGroupsContext = { + register: (filterGroupId: string, filterGroup: FilterGroup) => void; + unregister: (filterGroupId: string) => void; + setSelectedFilters: (filterGroupId: string, filters: string[]) => void; + filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; + matchingEntities: Entity[]; +}; + +/** + * The context that maintains shared state for all visible filter groups. + */ +export const filterGroupsContext = createContext( + {} as FilterGroupsContext, +); + +/** + * Implementation of the shared filter groups state. + */ +export const EntityFilterGroupsProvider = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const catalogApi = useApi(catalogApiRef); + const { value: entities, error } = useAsync(() => catalogApi.getEntities()); + + const [filterGroups, setFilterGroups] = useState<{ + [filterGroupId: string]: FilterGroup; + }>({}); + const [filterGroupStates, setFilterGroupStates] = useState<{ + [filterGroupId: string]: FilterGroupStates; + }>({}); + const [selectedFilterKeys, setSelectedFilterKeys] = useState>( + new Set(), + ); + const [matchingEntities, setMatchingEntities] = useState([]); + + useEffect(() => { + function buildStates(): { [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(filterGroupId); + const groupState: FilterGroupState = { filters: {} }; + for (const [filterId, filterFn] of Object.entries( + filterGroup.filters, + )) { + const isSelected = selectedFilterKeys.has( + `${filterGroupId}.${filterId}`, + ); + const matchCount = otherMatchingEntities.filter(entity => + filterFn(entity), + ).length; + groupState.filters[filterId] = { isSelected, matchCount }; + } + result[filterGroupId] = { type: 'ready', state: groupState }; + } + + return result; + } + + function buildMatchingEntities(excludeFilterGroupId?: string): Entity[] { + // Build one filter fn per filter group + const allFilters: ((entity: Entity) => boolean)[] = []; + 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: ((entity: Entity) => boolean)[] = []; + for (const [filterId, filterFn] of Object.entries( + filterGroup.filters, + )) { + if (selectedFilterKeys.has(`${filterGroupId}.${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))) ?? [] + ); + } + + setFilterGroupStates(buildStates()); + setMatchingEntities(buildMatchingEntities()); + }, [entities, error, filterGroups, selectedFilterKeys]); + + const register = useCallback( + (filterGroupId: string, filterGroup: FilterGroup) => { + setFilterGroups(oldGroups => ({ + ...oldGroups, + [filterGroupId]: filterGroup, + })); + }, + [], + ); + + const unregister = useCallback((filterGroupId: string) => { + setFilterGroups(oldGroups => { + const copy = { ...oldGroups }; + delete copy[filterGroupId]; + return copy; + }); + setFilterGroupStates(oldStates => { + const copy = { ...oldStates }; + delete copy[filterGroupId]; + return copy; + }); + }, []); + + const setSelectedFilters = useCallback( + (filterGroupId: string, filters: string[]) => { + const result = new Set(); + for (const key of selectedFilterKeys) { + if (!key.startsWith(`${filterGroupId}.`)) { + result.add(key); + } + } + for (const key of filters) { + result.add(`${filterGroupId}.${key}`); + } + setSelectedFilterKeys(result); + }, + [setSelectedFilterKeys], + ); + + const state: FilterGroupsContext = { + register, + unregister, + setSelectedFilters, + filterGroupStates, + matchingEntities, + }; + + return ( + + {children} + + ); +}; + +type EntityFilterGroupOutput = { + state: FilterGroupStates; + selectItems: (filters: string[]) => void; +}; + +/** + * Hook that exposes the relevant data and operations for a single filter + * group. + */ +export const useEntityFilterGroup = ( + filterGroupId: string, + filterGroup: FilterGroup, +): EntityFilterGroupOutput => { + const groupsContext = useContext(filterGroupsContext); + if (!groupsContext) { + throw new Error('You must be inside an EntityFilterGroupsProvider'); + } + + useEffect(() => { + groupsContext.register(filterGroupId, filterGroup); + return () => groupsContext.unregister(filterGroupId); + }, []); + + const selectItems = useCallback( + (filters: string[]) => { + groupsContext.setSelectedFilters(filterGroupId, filters); + }, + [groupsContext, filterGroupId], + ); + + let state = groupsContext.filterGroupStates[filterGroupId]; + if (!state) { + state = { type: 'loading' }; + } + + return { state, selectItems }; +}; From 410f98e2925a998b9868766bd565daf5e267c8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 25 Jun 2020 17:10:41 +0200 Subject: [PATCH 12/22] Clean up and finalize --- .../CatalogFilter/CatalogFilter.test.tsx | 221 ++++++---- .../CatalogFilter/CatalogFilter.tsx | 127 ++++-- .../CatalogPage/CatalogPage.test.tsx | 77 ++-- .../components/CatalogPage/CatalogPage.tsx | 118 ++--- .../components/CatalogPage/CatalogTabs.tsx | 55 +++ .../components/CatalogPage/WelcomeBanner.tsx | 55 +++ .../components/EntityPage/EntityPage.test.tsx | 4 +- plugins/catalog/src/data/filters.ts | 19 +- .../src/filter/EntityFilterGroupsProvider.tsx | 205 +++++++++ plugins/catalog/src/filter/context.ts | 38 ++ plugins/catalog/src/filter/index.ts | 28 ++ plugins/catalog/src/filter/types.ts | 53 +++ .../useEntityFilterGroup.test.tsx | 42 +- .../src/filter/useEntityFilterGroup.ts | 69 +++ .../catalog/src/filter/useFilteredEntities.ts | 34 ++ plugins/catalog/src/hooks/useEntities.tsx | 404 ------------------ .../src/hooks/useEntityFilterGroup.tsx | 273 ------------ 17 files changed, 870 insertions(+), 952 deletions(-) create mode 100644 plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx create mode 100644 plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx create mode 100644 plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx create mode 100644 plugins/catalog/src/filter/context.ts create mode 100644 plugins/catalog/src/filter/index.ts create mode 100644 plugins/catalog/src/filter/types.ts rename plugins/catalog/src/{hooks => filter}/useEntityFilterGroup.test.tsx (74%) create mode 100644 plugins/catalog/src/filter/useEntityFilterGroup.ts create mode 100644 plugins/catalog/src/filter/useFilteredEntities.ts delete mode 100644 plugins/catalog/src/hooks/useEntities.tsx delete mode 100644 plugins/catalog/src/hooks/useEntityFilterGroup.tsx diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index cc4cc62fdb..9dd0caa3b4 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -14,63 +14,78 @@ * 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 { CatalogApi, catalogApiRef } from '../../api/types'; import { EntityGroup } from '../../data/filters'; +import { EntityFilterGroupsProvider } from '../../filter'; +import { CatalogFilter, CatalogFilterGroup } 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 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], - }, + + const indentityApi: Partial = { + getUserId: () => 'tools@example.com', }; + + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + it('should render the different groups', async () => { const mockGroups: CatalogFilterGroup[] = [ { name: 'Test Group 1', items: [] }, { name: 'Test Group 2', items: [] }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + const { findByText } = renderWrapped( + , ); - for (const group of mockGroups) { expect(await findByText(group.name)).toBeInTheDocument(); } @@ -93,19 +108,16 @@ describe('Catalog Filter', () => { }, ]; - 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 () => { + it('selects the first item if no desired initial one is set', async () => { const mockGroups: CatalogFilterGroup[] = [ { name: 'Test Group 1', @@ -113,33 +125,30 @@ describe('Catalog Filter', () => { { id: EntityGroup.ALL, label: 'First Label', - count: 3, }, { id: EntityGroup.STARRED, label: 'Second Label', - count: 1, }, ], }, ]; - 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: EntityGroup.ALL, + label: 'First Label', + }); + }); }); - it('should fire the callback when an item is clicked', async () => { + it('selects the initial item', async () => { const mockGroups: CatalogFilterGroup[] = [ { name: 'Test Group 1', @@ -147,39 +156,34 @@ describe('Catalog Filter', () => { { id: EntityGroup.ALL, label: 'First Label', - count: 100, }, { id: EntityGroup.STARRED, label: 'Second Label', - count: 400, }, ], }, ]; - 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: EntityGroup.STARRED, + label: 'Second Label', + }); + }); }); - it('should render a component when a function is passed to the count component', async () => { + it('can change the selected item', async () => { const mockGroups: CatalogFilterGroup[] = [ { name: 'Test Group 1', @@ -187,22 +191,55 @@ describe('Catalog Filter', () => { { id: EntityGroup.ALL, label: 'First Label', - count: () => BACKSTAGE!, }, { id: EntityGroup.STARRED, label: 'Second Label', - count: 400, }, ], }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + + const onChange = jest.fn(); + + const { findByText } = renderWrapped( + , ); - expect(await findByText('Test Group 1')).toBeInTheDocument(); + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: EntityGroup.ALL, + label: 'First Label', + }); + }); + + fireEvent.click(await findByText('Second Label')); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: EntityGroup.STARRED, + label: 'Second Label', + }); + }); + }); + + it('displays match counts properly', async () => { + const mockGroups: CatalogFilterGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: EntityGroup.OWNED, + label: 'First Label', + }, + ], + }, + ]; + + 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 e40f4806ed..71956e2e22 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -14,21 +14,26 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import { IconComponent, identityApiRef, useApi } from '@backstage/core'; import { Card, List, ListItemIcon, - ListItemText, ListItemSecondaryAction, - MenuItem, - Typography, - Theme, + ListItemText, 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, { FC, useCallback, useMemo, useState, useEffect } from 'react'; +import { + EntityFilterOptions, + entityFilters, + EntityGroup, +} from '../../data/filters'; +import { FilterGroup, useEntityFilterGroup } from '../../filter'; +import { useStarredEntities } from '../../hooks/useStarredEntites'; export type CatalogFilterItem = { id: EntityGroup; @@ -68,21 +73,43 @@ 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 Props = { + filterGroups: CatalogFilterGroup[]; + onChange?: (filterItem: CatalogFilterItem) => void; + initiallySelected?: EntityGroup; +}; + +export const CatalogFilter = ({ + filterGroups, + onChange, + initiallySelected, +}: Props) => { const classes = useStyles(); + const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(); + + const setCurrent = useCallback( + (item: CatalogFilterItem) => { + setCurrentFilter(item.id); + onChange?.(item); + }, + [onChange, setCurrentFilter], + ); + + // Make one initial onChange to inform the surroundings about the selected + // item + useEffect(() => { + const items = filterGroups.flatMap(g => g.items); + const item = items.find(i => i.id === initiallySelected) || items[0]; + if (item) { + onChange?.(item); + } + // intentionally only happens on startup + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - {groups.map(group => ( + {filterGroups.map(group => ( {group.name} @@ -94,10 +121,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 && ( @@ -111,7 +136,7 @@ export const CatalogFilter: FC<{ - {entitiesByFilter[item.id]?.length ?? '-'} + {getFilterCount(item.id) ?? '-'} ))} @@ -122,3 +147,55 @@ export const CatalogFilter: FC<{ ); }; + +function useFilter(): { + currentFilter: string; + setCurrentFilter: (filterId: string) => void; + getFilterCount: (filterId: string) => number | undefined; +} { + const [currentFilter, setCurrentFilter] = useState('OWNED'); + const { isStarredEntity } = useStarredEntities(); + const userId = useApi(identityApiRef).getUserId(); + + const filterGroup = useMemo(() => { + const result: FilterGroup = { filters: {} }; + const options: EntityFilterOptions = { + userId, + isStarred: isStarredEntity, + }; + for (const [filterId, filterFn] of Object.entries(entityFilters)) { + result.filters[filterId] = entity => filterFn(entity, options); + } + return result; + }, [isStarredEntity, userId]); + + const { setSelectedFilters, state } = useEntityFilterGroup( + 'primary-sidebar', + filterGroup, + ['OWNED'], + ); + + 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/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 7ba1c39bb7..b4dcc2cb20 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -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 = { 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 9329eb9317..be357dfed1 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -15,38 +15,31 @@ */ import { Entity, LocationSpec } from '@backstage/catalog-model'; -import { - Content, - ContentHeader, - DismissableBanner, - HeaderTabs, - SupportButton, -} from '@backstage/core'; -import CatalogLayout from './CatalogLayout'; +import { Content, ContentHeader, SupportButton } from '@backstage/core'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; -import { - Button, - Link, - makeStyles, - Typography, - withStyles, -} from '@material-ui/core'; +import { Button, makeStyles, 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 React, { useCallback, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { CatalogFilter } from '../CatalogFilter/CatalogFilter'; -import { CatalogTable } from '../CatalogTable/CatalogTable'; -import { useEntities } from '../../hooks/useEntities'; -import { findLocationForEntityMeta } from '../../data/utils'; import { - getCatalogFilterItemByType, EntityGroup, filterGroups, - labeledEntityTypes, + LabeledEntityType, } from '../../data/filters'; +import { findLocationForEntityMeta } from '../../data/utils'; +import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; +import { useStarredEntities } from '../../hooks/useStarredEntites'; +import { + CatalogFilter, + CatalogFilterItem, +} from '../CatalogFilter/CatalogFilter'; +import { CatalogTable } from '../CatalogTable/CatalogTable'; +import CatalogLayout from './CatalogLayout'; +import { CatalogTabs } from './CatalogTabs'; +import { WelcomeBanner } from './WelcomeBanner'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -55,27 +48,14 @@ 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 { isStarredEntity, toggleStarredEntity } = useStarredEntities(); + const { loading, error, matchingEntities } = useFilteredEntities(); + const [selectedTab, setSelectedTab] = useState(); + const [selectedSidebarItem, setSelectedSidebarItem] = useState(); const YellowStar = withStyles({ root: { @@ -105,9 +85,7 @@ export const CatalogPage: FC<{}> = () => { return location.target; } }; - const location = findLocationForEntityMeta(rowData.metadata); - return { icon: Edit, tooltip: 'Edit', @@ -129,33 +107,19 @@ export const CatalogPage: FC<{}> = () => { }, ]; + const onTabChanged = useCallback((type: LabeledEntityType) => { + setSelectedTab(type.label); + }, []); + const onSidebarChanged = useCallback((filterItem: CatalogFilterItem) => { + setSelectedSidebarItem(filterItem.label); + }, []); + return ( - { - selectTypeFilter(labeledEntityTypes[index as number].id); - }} - /> + - - - 👋🏼 - - Welcome to Backstage, we are happy to have you. Start by checking - out our{' '} - - getting started - {' '} - page. - - } - id="catalog_page_welcome_banner" - /> - + +