feat(catalog): implement useEntityFilterGroup hook
This commit is contained in:
committed by
Fredrik Adelöw
parent
5d7679d8be
commit
f895e4b4e3
@@ -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<TestEntitiy>(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<TestEntitiy>(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<TestEntitiy>(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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<EntityGroup, Entity[] | undefined>;
|
||||
|
||||
type UseEntities = {
|
||||
selectedFilter: EntityGroup | undefined;
|
||||
setSelectedFilter: (f: EntityGroup) => void;
|
||||
error: Error | null;
|
||||
toggleStarredEntity: any;
|
||||
isStarredEntity: (e: Entity) => boolean;
|
||||
entitiesByFilter: EntitiesByFilter;
|
||||
loading: boolean;
|
||||
selectedTypeFilter: string;
|
||||
selectTypeFilter: (id: string) => void;
|
||||
};
|
||||
|
||||
type EntityFilterGroupOutput<T> = {
|
||||
selectItems: (items: string[]) => void;
|
||||
filteredItems: T[];
|
||||
states: OutputState;
|
||||
};
|
||||
|
||||
type OutputState = { [key: string]: { isSelected: boolean; count: number } };
|
||||
|
||||
type FilterDefinition<T> = {
|
||||
[key: string]: {
|
||||
isSelected: boolean;
|
||||
filterFunction: (entity: T) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const useEntityFilterGroup = <T>(
|
||||
entities: T[],
|
||||
filterFunctions: FilterDefinition<T>,
|
||||
): EntityFilterGroupOutput<T> => {
|
||||
const [filterFuncs, setFilterFuncs] = useState<FilterDefinition<T>>(
|
||||
filterFunctions,
|
||||
);
|
||||
|
||||
// and
|
||||
// Object.entries(filterFuncs).filter(([_, {isSelected}]) => isSelected).map(([_, {filterFunction}]) => filterFunction).reduce((acc, func) => (acc.filter(func)), entities)
|
||||
|
||||
return {
|
||||
selectItems: (functionNames: Array<any>) => {
|
||||
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 (
|
||||
// <FilterSet>
|
||||
// <FilterSetItem
|
||||
// selected={selectedItems.production}
|
||||
// onClick={() => selectItem('production')}
|
||||
// >
|
||||
// Production ({counts.production})
|
||||
// </FilterSetItem>
|
||||
// </FilterSet>
|
||||
// );
|
||||
// };
|
||||
|
||||
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<string>(
|
||||
labeledEntityTypes[0].id,
|
||||
);
|
||||
|
||||
const entitiesByFilter = useMemo(() => {
|
||||
const filterEntities = (
|
||||
ents: Entity[] | undefined,
|
||||
filterId: EntityGroup,
|
||||
isStarred: (e: Entity) => boolean,
|
||||
user: string,
|
||||
) => {
|
||||
return ents
|
||||
?.filter((e: Entity) =>
|
||||
entityFilters[filterId](e, {
|
||||
isStarred: isStarred(e),
|
||||
userId: user,
|
||||
}),
|
||||
)
|
||||
.filter(e => entityTypeFilter(e, selectedTypeFilter));
|
||||
};
|
||||
const data = Object.keys(EntityGroup).reduce(
|
||||
(res, key) => ({
|
||||
...res,
|
||||
[key]: filterEntities(
|
||||
entities,
|
||||
key as EntityGroup,
|
||||
isStarredEntity,
|
||||
userId,
|
||||
),
|
||||
}),
|
||||
{} as EntitiesByFilter,
|
||||
);
|
||||
return data;
|
||||
}, [entities, isStarredEntity, userId, selectedTypeFilter]);
|
||||
|
||||
return {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
error,
|
||||
toggleStarredEntity,
|
||||
isStarredEntity,
|
||||
entitiesByFilter,
|
||||
loading: entities === undefined,
|
||||
selectedTypeFilter,
|
||||
selectTypeFilter,
|
||||
};
|
||||
};
|
||||
@@ -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<EntityGroup, Entity[] | undefined>;
|
||||
|
||||
type UseEntities = {
|
||||
selectedFilter: EntityGroup | undefined;
|
||||
setSelectedFilter: (f: EntityGroup) => void;
|
||||
error: Error | null;
|
||||
toggleStarredEntity: any;
|
||||
isStarredEntity: (e: Entity) => boolean;
|
||||
entitiesByFilter: EntitiesByFilter;
|
||||
loading: boolean;
|
||||
selectedTypeFilter: string;
|
||||
selectTypeFilter: (id: string) => void;
|
||||
};
|
||||
|
||||
export 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<FilterGroupsContext>(
|
||||
{} 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<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
|
||||
|
||||
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<string>();
|
||||
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 (
|
||||
<filterGroupsContext.Provider value={state}>
|
||||
{children}
|
||||
</filterGroupsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that exposes the relevant data and operations for a single filter
|
||||
* group.
|
||||
*/
|
||||
/*
|
||||
export const useEntityFilterGroup = (
|
||||
filterGroupId: string,
|
||||
filterGroup: FilterGroup,
|
||||
): EntityFilterGroupOutput<Entity> => {
|
||||
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<FilterDefinition<Entity>>(
|
||||
filterFunctions,
|
||||
);
|
||||
|
||||
// and
|
||||
// Object.entries(filterFuncs).filter(([_, {isSelected}]) => isSelected).map(([_, {filterFunction}]) => filterFunction).reduce((acc, func) => (acc.filter(func)), entities)
|
||||
|
||||
return {
|
||||
selectItems: (functionNames: Array<any>) => {
|
||||
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<string>(
|
||||
labeledEntityTypes[0].id,
|
||||
);
|
||||
|
||||
const entitiesByFilter = useMemo(() => {
|
||||
const filterEntities = (
|
||||
ents: Entity[] | undefined,
|
||||
filterId: EntityGroup,
|
||||
isStarred: (e: Entity) => boolean,
|
||||
user: string,
|
||||
) => {
|
||||
return ents
|
||||
?.filter((e: Entity) =>
|
||||
entityFilters[filterId](e, {
|
||||
isStarred: isStarred(e),
|
||||
userId: user,
|
||||
}),
|
||||
)
|
||||
.filter(e => entityTypeFilter(e, selectedTypeFilter));
|
||||
};
|
||||
const data = Object.keys(EntityGroup).reduce(
|
||||
(res, key) => ({
|
||||
...res,
|
||||
[key]: filterEntities(
|
||||
entities,
|
||||
key as EntityGroup,
|
||||
isStarredEntity,
|
||||
userId,
|
||||
),
|
||||
}),
|
||||
{} as EntitiesByFilter,
|
||||
);
|
||||
return data;
|
||||
}, [entities, isStarredEntity, userId, selectedTypeFilter]);
|
||||
|
||||
return {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
error,
|
||||
toggleStarredEntity,
|
||||
isStarredEntity,
|
||||
entitiesByFilter,
|
||||
loading: entities === undefined,
|
||||
selectedTypeFilter,
|
||||
selectTypeFilter,
|
||||
};
|
||||
};
|
||||
@@ -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<typeof catalogApiRef.T>;
|
||||
let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element;
|
||||
|
||||
beforeEach(() => {
|
||||
catalogApi = {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
|
||||
getEntities: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
};
|
||||
wrapper = ({ children }: { children?: React.ReactNode }) => (
|
||||
<ApiProvider apis={ApiRegistry.from([[catalogApiRef, catalogApi]])}>
|
||||
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<EntityGroup, Entity[] | undefined>;
|
||||
|
||||
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<FilterGroupsContext>(
|
||||
{} 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<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
|
||||
|
||||
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<string>();
|
||||
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 (
|
||||
<filterGroupsContext.Provider value={state}>
|
||||
{children}
|
||||
</filterGroupsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user