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 }; +};