From 5d7679d8becb363f40556f2a6950f5fbee317e3a Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Mon, 22 Jun 2020 16:32:05 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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" - /> - + +