feat(customHomePage/hierarchy): add support of pagination for root level nodes (#14485)

Co-authored-by: Chris Collins <chriscollins3456@gmail.com>
This commit is contained in:
v-tarasevich-blitz-brain 2025-08-22 18:29:16 +03:00 committed by GitHub
parent 85fa97623b
commit 8234cbec8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 593 additions and 105 deletions

View File

@ -1,13 +1,13 @@
import React from 'react'; import React from 'react';
import { ChildrenLoaderContextType } from '@app/homeV3/modules/hierarchyViewModule/childrenLoader/types'; import { ChildrenLoaderContextType } from '@app/homeV3/modules/hierarchyViewModule/childrenLoader/types';
import { DEFAULT_NUMBER_OF_CHILDREN_TO_LOAD } from '@app/homeV3/modules/hierarchyViewModule/treeView/constants'; import { DEFAULT_LOAD_BATCH_SIZE } from '@app/homeV3/modules/hierarchyViewModule/treeView/constants';
const DEFAULT_CONTEXT_STATE: ChildrenLoaderContextType = { const DEFAULT_CONTEXT_STATE: ChildrenLoaderContextType = {
get: () => undefined, get: () => undefined,
upsert: () => {}, upsert: () => {},
onLoad: () => {}, onLoad: () => {},
maxNumberOfChildrenToLoad: DEFAULT_NUMBER_OF_CHILDREN_TO_LOAD, maxNumberOfChildrenToLoad: DEFAULT_LOAD_BATCH_SIZE,
}; };
const ChildrenLoaderContext = React.createContext<ChildrenLoaderContextType>(DEFAULT_CONTEXT_STATE); const ChildrenLoaderContext = React.createContext<ChildrenLoaderContextType>(DEFAULT_CONTEXT_STATE);

View File

@ -2,7 +2,7 @@ import React, { useCallback, useState } from 'react';
import ChildrenLoaderContext from '@app/homeV3/modules/hierarchyViewModule/childrenLoader/context/ChildrenLoaderContext'; import ChildrenLoaderContext from '@app/homeV3/modules/hierarchyViewModule/childrenLoader/context/ChildrenLoaderContext';
import { ChildrenLoaderMetadata, MetadataMap } from '@app/homeV3/modules/hierarchyViewModule/childrenLoader/types'; import { ChildrenLoaderMetadata, MetadataMap } from '@app/homeV3/modules/hierarchyViewModule/childrenLoader/types';
import { DEFAULT_NUMBER_OF_CHILDREN_TO_LOAD } from '@app/homeV3/modules/hierarchyViewModule/treeView/constants'; import { DEFAULT_LOAD_BATCH_SIZE } from '@app/homeV3/modules/hierarchyViewModule/treeView/constants';
import { TreeNode } from '@app/homeV3/modules/hierarchyViewModule/treeView/types'; import { TreeNode } from '@app/homeV3/modules/hierarchyViewModule/treeView/types';
interface Props { interface Props {
@ -13,7 +13,7 @@ interface Props {
export function ChildrenLoaderProvider({ export function ChildrenLoaderProvider({
children, children,
onLoadFinished, onLoadFinished,
maxNumberOfChildrenToLoad = DEFAULT_NUMBER_OF_CHILDREN_TO_LOAD, maxNumberOfChildrenToLoad = DEFAULT_LOAD_BATCH_SIZE,
}: React.PropsWithChildren<Props>) { }: React.PropsWithChildren<Props>) {
const [metadataMap, setMetadataMap] = useState<MetadataMap>({}); const [metadataMap, setMetadataMap] = useState<MetadataMap>({});

View File

@ -12,10 +12,15 @@ import useSelectableDomainTree from '@app/homeV3/modules/hierarchyViewModule/com
import { useHierarchyFormContext } from '@app/homeV3/modules/hierarchyViewModule/components/form/HierarchyFormContext'; import { useHierarchyFormContext } from '@app/homeV3/modules/hierarchyViewModule/components/form/HierarchyFormContext';
import TreeView from '@app/homeV3/modules/hierarchyViewModule/treeView/TreeView'; import TreeView from '@app/homeV3/modules/hierarchyViewModule/treeView/TreeView';
import { TreeNode } from '@app/homeV3/modules/hierarchyViewModule/treeView/types'; import { TreeNode } from '@app/homeV3/modules/hierarchyViewModule/treeView/types';
import { getTopLevelSelectedValuesFromTree } from '@app/homeV3/modules/hierarchyViewModule/treeView/utils'; import {
getOutOfTreeSelectedValues,
getTopLevelSelectedValuesFromTree,
} from '@app/homeV3/modules/hierarchyViewModule/treeView/utils';
const Wrapper = styled.div``; const Wrapper = styled.div``;
const LOAD_BATCH_SIZE = 25;
export default function DomainsSelectableTreeView() { export default function DomainsSelectableTreeView() {
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const { const {
@ -24,12 +29,23 @@ export default function DomainsSelectableTreeView() {
const { parentValues, addParentValue, removeParentValue } = useParentValuesToLoadChildren(); const { parentValues, addParentValue, removeParentValue } = useParentValuesToLoadChildren();
const { tree, selectedValues, setSelectedValues, loading } = useSelectableDomainTree(initialSelectedValues); const {
tree,
selectedValues,
setSelectedValues,
loading,
loadMoreRootNodes,
rootDomainsMoreLoading,
rootNodesTotal,
} = useSelectableDomainTree(initialSelectedValues, LOAD_BATCH_SIZE);
const updateSelectedValues = useCallback( const updateSelectedValues = useCallback(
(newSelectedValues: string[]) => { (newSelectedValues: string[]) => {
const topLevelSelectedValues = getTopLevelSelectedValuesFromTree(newSelectedValues, tree.nodes); const topLevelSelectedValues = getTopLevelSelectedValuesFromTree(newSelectedValues, tree.nodes);
form.setFieldValue('domainAssets', topLevelSelectedValues); // add out of tree selected values as some root nodes could not be loaded yet
const outOfTreeSelectedValues = getOutOfTreeSelectedValues(newSelectedValues, tree.nodes);
const newProcessedSelectedValues = new Set([...topLevelSelectedValues, ...outOfTreeSelectedValues]);
form.setFieldValue('domainAssets', Array.from(newProcessedSelectedValues));
setSelectedValues(newSelectedValues); setSelectedValues(newSelectedValues);
}, },
[form, setSelectedValues, tree], [form, setSelectedValues, tree],
@ -57,7 +73,7 @@ export default function DomainsSelectableTreeView() {
return ( return (
<Wrapper> <Wrapper>
<ChildrenLoaderProvider onLoadFinished={onLoadFinished}> <ChildrenLoaderProvider onLoadFinished={onLoadFinished} maxNumberOfChildrenToLoad={LOAD_BATCH_SIZE}>
<ChildrenLoader parentValues={parentValues} loadChildren={useChildrenDomainsLoader} /> <ChildrenLoader parentValues={parentValues} loadChildren={useChildrenDomainsLoader} />
<TreeView <TreeView
@ -70,6 +86,11 @@ export default function DomainsSelectableTreeView() {
updateSelectedValues={updateSelectedValues} updateSelectedValues={updateSelectedValues}
loadChildren={startLoadingOfChildren} loadChildren={startLoadingOfChildren}
renderNodeLabel={(nodeProps) => <DomainSelectableTreeNodeRenderer {...nodeProps} />} renderNodeLabel={(nodeProps) => <DomainSelectableTreeNodeRenderer {...nodeProps} />}
loadRootNodes={loadMoreRootNodes}
rootNodesLoading={rootDomainsMoreLoading}
loadingTriggerType="infiniteScroll"
rootNodesTotal={rootNodesTotal}
loadBatchSize={LOAD_BATCH_SIZE}
/> />
</ChildrenLoaderProvider> </ChildrenLoaderProvider>
</Wrapper> </Wrapper>

View File

@ -1,8 +1,10 @@
import { ApolloQueryResult, NetworkStatus } from '@apollo/client';
import { act } from '@testing-library/react'; import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import useInitialDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useInitialDomains'; import useInitialDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useInitialDomains';
import useLoadMoreRootDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useLoadMoreRootDomains';
import useRootDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useRootDomains'; import useRootDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useRootDomains';
import useSelectableDomainTree from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useSelectableDomainTree'; import useSelectableDomainTree from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useSelectableDomainTree';
import useTreeNodesFromFlatDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromFlatDomains'; import useTreeNodesFromFlatDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromFlatDomains';
@ -10,11 +12,13 @@ import useTreeNodesFromDomains from '@app/homeV3/modules/hierarchyViewModule/com
import useTree from '@app/homeV3/modules/hierarchyViewModule/treeView/useTree'; import useTree from '@app/homeV3/modules/hierarchyViewModule/treeView/useTree';
import { mergeTrees } from '@app/homeV3/modules/hierarchyViewModule/treeView/utils'; import { mergeTrees } from '@app/homeV3/modules/hierarchyViewModule/treeView/utils';
import { EntityType } from '@types'; import { GetSearchResultsForMultipleQuery } from '@graphql/search.generated';
import { Domain, EntityType } from '@types';
// Mock all the dependent hooks // Mock all the dependent hooks
vi.mock('@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useInitialDomains'); vi.mock('@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useInitialDomains');
vi.mock('@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useRootDomains'); vi.mock('@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useRootDomains');
vi.mock('@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useLoadMoreRootDomains');
vi.mock('@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromFlatDomains'); vi.mock('@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromFlatDomains');
vi.mock('@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromListDomains'); vi.mock('@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromListDomains');
vi.mock('@app/homeV3/modules/hierarchyViewModule/treeView/useTree'); vi.mock('@app/homeV3/modules/hierarchyViewModule/treeView/useTree');
@ -22,11 +26,18 @@ vi.mock('@app/homeV3/modules/hierarchyViewModule/treeView/utils');
const mockUseInitialDomains = vi.mocked(useInitialDomains); const mockUseInitialDomains = vi.mocked(useInitialDomains);
const mockUseRootDomains = vi.mocked(useRootDomains); const mockUseRootDomains = vi.mocked(useRootDomains);
const mockUseLoadMoreRootDomains = vi.mocked(useLoadMoreRootDomains);
const mockUseTreeNodesFromFlatDomains = vi.mocked(useTreeNodesFromFlatDomains); const mockUseTreeNodesFromFlatDomains = vi.mocked(useTreeNodesFromFlatDomains);
const mockUseTreeNodesFromDomains = vi.mocked(useTreeNodesFromDomains); const mockUseTreeNodesFromDomains = vi.mocked(useTreeNodesFromDomains);
const mockUseTree = vi.mocked(useTree); const mockUseTree = vi.mocked(useTree);
const mockMergeTrees = vi.mocked(mergeTrees); const mockMergeTrees = vi.mocked(mergeTrees);
const mockRefetch = (): Promise<ApolloQueryResult<GetSearchResultsForMultipleQuery>> => {
return new Promise<ApolloQueryResult<GetSearchResultsForMultipleQuery>>((resolve) => {
resolve({ data: { searchAcrossEntities: undefined }, networkStatus: NetworkStatus.refetch, loading: false });
});
};
// Mock tree object with dynamic nodes // Mock tree object with dynamic nodes
const mockTreeMethods = { const mockTreeMethods = {
nodes: [] as any[], nodes: [] as any[],
@ -75,7 +86,20 @@ describe('useSelectableDomainTree hook', () => {
// Setup default mock implementations // Setup default mock implementations
mockUseInitialDomains.mockReturnValue({ data: undefined, domains: [], loading: false }); mockUseInitialDomains.mockReturnValue({ data: undefined, domains: [], loading: false });
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [], total: 0, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [],
total: 0,
loading: false,
refetch: mockRefetch,
});
mockUseLoadMoreRootDomains.mockReturnValue({
loading: false,
loadMoreRootDomains: () =>
new Promise<Domain[]>((resolve) => {
resolve([]);
}),
});
mockUseTreeNodesFromFlatDomains.mockReturnValue(undefined); mockUseTreeNodesFromFlatDomains.mockReturnValue(undefined);
mockUseTreeNodesFromDomains.mockReturnValue(undefined); mockUseTreeNodesFromDomains.mockReturnValue(undefined);
mockUseTree.mockReturnValue(mockTreeMethods); mockUseTree.mockReturnValue(mockTreeMethods);
@ -115,7 +139,13 @@ describe('useSelectableDomainTree hook', () => {
describe('tree initialization', () => { describe('tree initialization', () => {
it('should not initialize tree when root domains are loading', () => { it('should not initialize tree when root domains are loading', () => {
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [], total: 0, loading: true }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [],
total: 0,
loading: true,
refetch: mockRefetch,
});
renderHook(() => useSelectableDomainTree([])); renderHook(() => useSelectableDomainTree([]));
@ -123,7 +153,13 @@ describe('useSelectableDomainTree hook', () => {
}); });
it('should not initialize tree when root tree nodes are undefined', () => { it('should not initialize tree when root tree nodes are undefined', () => {
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [mockDomain1], total: 1, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [mockDomain1],
total: 1,
loading: false,
refetch: mockRefetch,
});
mockUseTreeNodesFromDomains.mockReturnValue(undefined); mockUseTreeNodesFromDomains.mockReturnValue(undefined);
renderHook(() => useSelectableDomainTree([])); renderHook(() => useSelectableDomainTree([]));
@ -132,7 +168,13 @@ describe('useSelectableDomainTree hook', () => {
}); });
it('should not initialize tree when initial selected tree nodes are undefined', () => { it('should not initialize tree when initial selected tree nodes are undefined', () => {
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [mockDomain1], total: 1, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [mockDomain1],
total: 1,
loading: false,
refetch: mockRefetch,
});
mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]); mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]);
mockUseTreeNodesFromFlatDomains.mockReturnValue(undefined); mockUseTreeNodesFromFlatDomains.mockReturnValue(undefined);
@ -146,21 +188,33 @@ describe('useSelectableDomainTree hook', () => {
const initialSelectedTreeNodes = [mockTreeNode2]; const initialSelectedTreeNodes = [mockTreeNode2];
const mergedTreeNodes = [mockTreeNode1, mockTreeNode2]; const mergedTreeNodes = [mockTreeNode1, mockTreeNode2];
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [mockDomain1], total: 1, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [mockDomain1],
total: 1,
loading: false,
refetch: mockRefetch,
});
mockUseTreeNodesFromDomains.mockReturnValue(rootTreeNodes); mockUseTreeNodesFromDomains.mockReturnValue(rootTreeNodes);
mockUseTreeNodesFromFlatDomains.mockReturnValue(initialSelectedTreeNodes); mockUseTreeNodesFromFlatDomains.mockReturnValue(initialSelectedTreeNodes);
mockMergeTrees.mockReturnValue(mergedTreeNodes); mockMergeTrees.mockReturnValue(mergedTreeNodes);
renderHook(() => useSelectableDomainTree([])); renderHook(() => useSelectableDomainTree([]));
expect(mockMergeTrees).toHaveBeenCalledWith(rootTreeNodes, initialSelectedTreeNodes); expect(mockMergeTrees).toHaveBeenCalledWith(rootTreeNodes, []);
expect(mockTreeMethods.replace).toHaveBeenCalledWith(mergedTreeNodes); expect(mockTreeMethods.replace).toHaveBeenCalledWith(mergedTreeNodes);
}); });
}); });
describe('selectedValues management', () => { describe('selectedValues management', () => {
beforeEach(() => { beforeEach(() => {
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [mockDomain1], total: 1, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [mockDomain1],
total: 1,
loading: false,
refetch: mockRefetch,
});
mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]); mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]);
mockUseTreeNodesFromFlatDomains.mockReturnValue([mockTreeNode2]); mockUseTreeNodesFromFlatDomains.mockReturnValue([mockTreeNode2]);
}); });
@ -185,7 +239,13 @@ describe('useSelectableDomainTree hook', () => {
}); });
it('should handle loading state correctly when root domains are loading', () => { it('should handle loading state correctly when root domains are loading', () => {
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [], total: 0, loading: true }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [],
total: 0,
loading: true,
refetch: mockRefetch,
});
const { result } = renderHook(() => useSelectableDomainTree([])); const { result } = renderHook(() => useSelectableDomainTree([]));
@ -193,7 +253,13 @@ describe('useSelectableDomainTree hook', () => {
}); });
it('should handle ready state when all data is loaded', () => { it('should handle ready state when all data is loaded', () => {
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [mockDomain1], total: 1, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [mockDomain1],
total: 1,
loading: false,
refetch: mockRefetch,
});
mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]); mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]);
const { result } = renderHook(() => useSelectableDomainTree([])); const { result } = renderHook(() => useSelectableDomainTree([]));
@ -204,7 +270,13 @@ describe('useSelectableDomainTree hook', () => {
describe('tree method delegation', () => { describe('tree method delegation', () => {
beforeEach(() => { beforeEach(() => {
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [mockDomain1], total: 1, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [mockDomain1],
total: 1,
loading: false,
refetch: mockRefetch,
});
mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]); mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]);
mockUseTreeNodesFromFlatDomains.mockReturnValue([mockTreeNode2]); mockUseTreeNodesFromFlatDomains.mockReturnValue([mockTreeNode2]);
}); });
@ -254,7 +326,13 @@ describe('useSelectableDomainTree hook', () => {
it('should use domains from root domains hook', () => { it('should use domains from root domains hook', () => {
const rootDomains = [mockDomain1]; const rootDomains = [mockDomain1];
mockUseRootDomains.mockReturnValue({ data: undefined, domains: rootDomains, total: 1, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: rootDomains,
total: 1,
loading: false,
refetch: mockRefetch,
});
renderHook(() => useSelectableDomainTree([])); renderHook(() => useSelectableDomainTree([]));
@ -262,14 +340,20 @@ describe('useSelectableDomainTree hook', () => {
}); });
it('should handle mixed states correctly', () => { it('should handle mixed states correctly', () => {
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [mockDomain1], total: 1, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [mockDomain1],
total: 1,
loading: false,
refetch: mockRefetch,
});
mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]); mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]);
mockUseTreeNodesFromFlatDomains.mockReturnValue([mockTreeNode2]); mockUseTreeNodesFromFlatDomains.mockReturnValue([mockTreeNode2]);
const { result } = renderHook(() => useSelectableDomainTree([])); const { result } = renderHook(() => useSelectableDomainTree([]));
expect(result.current.loading).toBe(false); expect(result.current.loading).toBe(false);
expect(mockMergeTrees).toHaveBeenCalledWith([mockTreeNode1], [mockTreeNode2]); expect(mockMergeTrees).toHaveBeenCalledWith([mockTreeNode1], []);
}); });
it('should handle empty initial domains', () => { it('should handle empty initial domains', () => {
@ -281,7 +365,13 @@ describe('useSelectableDomainTree hook', () => {
}); });
it('should handle empty root domains', () => { it('should handle empty root domains', () => {
mockUseRootDomains.mockReturnValue({ data: undefined, domains: [mockDomain1], total: 1, loading: false }); mockUseRootDomains.mockReturnValue({
data: undefined,
domains: [mockDomain1],
total: 1,
loading: false,
refetch: mockRefetch,
});
mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]); mockUseTreeNodesFromDomains.mockReturnValue([mockTreeNode1]);
mockUseTreeNodesFromFlatDomains.mockReturnValue([mockTreeNode2]); mockUseTreeNodesFromFlatDomains.mockReturnValue([mockTreeNode2]);

View File

@ -1,14 +1,28 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useListDomainsQuery } from '@graphql/domain.generated'; import { isDomain } from '@app/entityV2/domain/utils';
import { ENTITY_NAME_FIELD } from '@app/searchV2/context/constants';
export default function useDomains(parentDomainUrns: string | undefined, start: number, count: number, skip: boolean) { import { useGetSearchResultsForMultipleQuery } from '@graphql/search.generated';
const { data, loading } = useListDomainsQuery({ import { EntityType, FilterOperator, SortOrder } from '@types';
export default function useDomains(parentDomainUrn: string | undefined, start: number, count: number, skip: boolean) {
const { data, loading, refetch } = useGetSearchResultsForMultipleQuery({
variables: { variables: {
input: { input: {
start, start,
count, count,
parentDomain: parentDomainUrns, query: '*',
types: [EntityType.Domain],
orFilters: parentDomainUrn
? [{ and: [{ field: 'parentDomain', values: [parentDomainUrn] }] }]
: [{ and: [{ field: 'parentDomain', condition: FilterOperator.Exists, negated: true }] }],
sortInput: {
sortCriteria: [{ field: ENTITY_NAME_FIELD, sortOrder: SortOrder.Ascending }],
},
searchFlags: {
skipCache: true,
},
}, },
}, },
skip, skip,
@ -16,10 +30,10 @@ export default function useDomains(parentDomainUrns: string | undefined, start:
const domains = useMemo(() => { const domains = useMemo(() => {
if (skip) return []; if (skip) return [];
return data?.listDomains?.domains; return data?.searchAcrossEntities?.searchResults.map((result) => result.entity).filter(isDomain);
}, [data, skip]); }, [data, skip]);
const total = useMemo(() => data?.listDomains?.total, [data]); const total = useMemo(() => data?.searchAcrossEntities?.total, [data]);
return { data, domains, total, loading }; return { data, domains, total, loading, refetch };
} }

View File

@ -0,0 +1,66 @@
import { useCallback, useState } from 'react';
import { isDomain } from '@app/entityV2/domain/utils';
import { ENTITY_NAME_FIELD } from '@app/searchV2/context/constants';
import { useGetSearchResultsForMultipleQuery } from '@graphql/search.generated';
import { EntityType, FilterOperator, SortOrder } from '@types';
export default function useLoadMoreRootDomains() {
const [loading, setLoading] = useState<boolean>(false);
const { refetch } = useGetSearchResultsForMultipleQuery({
variables: {
input: {
query: '*',
types: [EntityType.Domain],
start: 0,
count: 0,
sortInput: {
sortCriteria: [{ field: ENTITY_NAME_FIELD, sortOrder: SortOrder.Ascending }],
},
},
},
skip: true,
});
const loadMoreRootDomains = useCallback(
async (start: number, pageSize: number) => {
setLoading(true);
try {
const response = await refetch({
input: {
start,
types: [EntityType.Domain],
query: '*',
count: pageSize,
orFilters: [
{ and: [{ field: 'parentDomain', condition: FilterOperator.Exists, negated: true }] },
],
sortInput: {
sortCriteria: [{ field: ENTITY_NAME_FIELD, sortOrder: SortOrder.Ascending }],
},
},
});
return (
response.data.searchAcrossEntities?.searchResults
?.map((searchResult) => searchResult.entity)
.filter(isDomain) ?? []
);
} catch (e) {
console.error('Something went wrong during fetching root domains', e);
return [];
} finally {
setLoading(false);
}
},
[refetch],
);
return {
loadMoreRootDomains,
loading,
};
}

View File

@ -1,7 +1,5 @@
import useDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useDomains'; import useDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useDomains';
const MAX_ROOT_DOMAINS = 1000; export default function useRootDomains(count: number) {
return useDomains(undefined, 0, count, false);
export default function useRootDomains() {
return useDomains(undefined, 0, MAX_ROOT_DOMAINS, false);
} }

View File

@ -1,25 +1,64 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import useDomainTreeNodesSorter from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useDomainTreeNodesSorter'; import useDomainTreeNodesSorter from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useDomainTreeNodesSorter';
import useInitialDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useInitialDomains'; import useInitialDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useInitialDomains';
import useLoadMoreRootDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useLoadMoreRootDomains';
import useRootDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useRootDomains'; import useRootDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useRootDomains';
import useTreeNodesFromFlatDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromFlatDomains'; import useTreeNodesFromFlatDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromFlatDomains';
import useTreeNodesFromDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromListDomains'; import useTreeNodesFromDomains from '@app/homeV3/modules/hierarchyViewModule/components/domains/hooks/useTreeNodesFromListDomains';
import { convertDomainToTreeNode } from '@app/homeV3/modules/hierarchyViewModule/components/domains/utils';
import { DEFAULT_LOAD_BATCH_SIZE } from '@app/homeV3/modules/hierarchyViewModule/treeView/constants';
import { TreeNode } from '@app/homeV3/modules/hierarchyViewModule/treeView/types';
import useTree from '@app/homeV3/modules/hierarchyViewModule/treeView/useTree'; import useTree from '@app/homeV3/modules/hierarchyViewModule/treeView/useTree';
import { mergeTrees } from '@app/homeV3/modules/hierarchyViewModule/treeView/utils'; import { mergeTrees } from '@app/homeV3/modules/hierarchyViewModule/treeView/utils';
export default function useSelectableDomainTree(initialSelectedDomainUrns: string[] | undefined) { export default function useSelectableDomainTree(
initialSelectedDomainUrns: string[] | undefined,
loadBatchSize = DEFAULT_LOAD_BATCH_SIZE,
) {
const [isInitialized, setIsInitialized] = useState<boolean>(false); const [isInitialized, setIsInitialized] = useState<boolean>(false);
const [selectedValues, setSelectedValues] = useState<string[]>(initialSelectedDomainUrns ?? []); const [selectedValues, setSelectedValues] = useState<string[]>(initialSelectedDomainUrns ?? []);
const nodesSorter = useDomainTreeNodesSorter(); const nodesSorter = useDomainTreeNodesSorter();
const tree = useTree(undefined, nodesSorter); const tree = useTree(undefined, nodesSorter);
// FYI: We get and extract parents from initially selected domains
// to have possibility to detect if some node has any selected nested nodes
const { domains: initialDomains } = useInitialDomains(initialSelectedDomainUrns ?? []); const { domains: initialDomains } = useInitialDomains(initialSelectedDomainUrns ?? []);
const initialSelectedTreeNodes = useTreeNodesFromFlatDomains(initialDomains); const initialSelectedTreeNodes = useTreeNodesFromFlatDomains(initialDomains);
const { domains: rootDomains, loading: rootDomainsLoading } = useRootDomains(); const {
domains: rootDomains,
loading: rootDomainsLoading,
total: rootDomainsTotal,
} = useRootDomains(loadBatchSize);
const rootTreeNodes = useTreeNodesFromDomains(rootDomains, false); const rootTreeNodes = useTreeNodesFromDomains(rootDomains, false);
const { loadMoreRootDomains, loading: rootDomainsMoreLoading } = useLoadMoreRootDomains();
const preprocessRootNodes = useCallback(
(rootNodes: TreeNode[]) => {
if (!initialSelectedTreeNodes) return rootNodes;
// Merge initial selected nodes with the same root nodes
const initialSelectedTreeNodesToMerge = initialSelectedTreeNodes.filter((initialSelectedTreeNode) =>
rootNodes.some((rootNode) => rootNode.value === initialSelectedTreeNode.value),
);
return mergeTrees(rootNodes, initialSelectedTreeNodesToMerge);
},
[initialSelectedTreeNodes],
);
const loadMoreRootNodes = useCallback(async () => {
const hasMoreRootNodes = (rootDomainsTotal ?? 0) > tree.nodes.length;
if (!rootDomainsMoreLoading && hasMoreRootNodes) {
const domains = await loadMoreRootDomains(tree.nodes.length, loadBatchSize);
if (domains) {
const treeNodes = domains.map((domain) => convertDomainToTreeNode(domain));
tree.merge(preprocessRootNodes(treeNodes));
}
}
}, [tree, loadMoreRootDomains, rootDomainsTotal, rootDomainsMoreLoading, preprocessRootNodes, loadBatchSize]);
useEffect(() => { useEffect(() => {
if ( if (
!rootDomainsLoading && !rootDomainsLoading &&
@ -27,15 +66,18 @@ export default function useSelectableDomainTree(initialSelectedDomainUrns: strin
rootTreeNodes !== undefined && rootTreeNodes !== undefined &&
initialSelectedTreeNodes !== undefined initialSelectedTreeNodes !== undefined
) { ) {
tree.replace(mergeTrees(rootTreeNodes, initialSelectedTreeNodes)); tree.replace(preprocessRootNodes(rootTreeNodes));
setIsInitialized(true); setIsInitialized(true);
} }
}, [tree, isInitialized, rootTreeNodes, initialSelectedTreeNodes, rootDomainsLoading]); }, [tree, isInitialized, rootTreeNodes, initialSelectedTreeNodes, rootDomainsLoading, preprocessRootNodes]);
return { return {
tree, tree,
loading: !isInitialized, loading: !isInitialized,
selectedValues, selectedValues,
setSelectedValues, setSelectedValues,
loadMoreRootNodes,
rootDomainsMoreLoading,
rootNodesTotal: rootDomainsTotal,
}; };
} }

View File

@ -1,27 +1,20 @@
import { Button, Checkbox, Loader } from '@components'; import { Checkbox } from '@components';
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import ChildrenLoader from '@app/homeV3/modules/hierarchyViewModule/treeView/ChildrenLoader'; import ChildrenLoader from '@app/homeV3/modules/hierarchyViewModule/treeView/ChildrenLoader';
import DepthMargin from '@app/homeV3/modules/hierarchyViewModule/treeView/DepthMargin'; import DepthMargin from '@app/homeV3/modules/hierarchyViewModule/treeView/DepthMargin';
import ExpandToggler from '@app/homeV3/modules/hierarchyViewModule/treeView/ExpandToggler'; import ExpandToggler from '@app/homeV3/modules/hierarchyViewModule/treeView/ExpandToggler';
import Row from '@app/homeV3/modules/hierarchyViewModule/treeView/components/Row';
import TreeNodesViewLoader from '@app/homeV3/modules/hierarchyViewModule/treeView/components/TreeNodesViewLoader';
import NodesLoaderWrapper from '@app/homeV3/modules/hierarchyViewModule/treeView/components/itemsLoaderWrapper/NodesLoaderWrapper';
import useTreeViewContext from '@app/homeV3/modules/hierarchyViewModule/treeView/context/useTreeViewContext'; import useTreeViewContext from '@app/homeV3/modules/hierarchyViewModule/treeView/context/useTreeViewContext';
import { TreeNode } from '@app/homeV3/modules/hierarchyViewModule/treeView/types'; import { TreeNode } from '@app/homeV3/modules/hierarchyViewModule/treeView/types';
const Row = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
`;
const SpaceFiller = styled.div` const SpaceFiller = styled.div`
flex-grow: 1; flex-grow: 1;
`; `;
const LoaderWrapper = styled.div``;
interface Props { interface Props {
node: TreeNode; node: TreeNode;
depth: number; depth: number;
@ -29,8 +22,11 @@ interface Props {
export default function TreeNodeRenderer({ node, depth }: Props) { export default function TreeNodeRenderer({ node, depth }: Props) {
const { const {
loadingTriggerType,
getHasParentNode, getHasParentNode,
getIsRootNode, getIsRootNode,
getChildrenTotal,
getChildrenLength,
renderNodeLabel, renderNodeLabel,
getIsExpandable, getIsExpandable,
getIsExpanded, getIsExpanded,
@ -45,9 +41,8 @@ export default function TreeNodeRenderer({ node, depth }: Props) {
enableIntermediateSelectState, enableIntermediateSelectState,
toggleSelected, toggleSelected,
getIsChildrenLoading, getIsChildrenLoading,
getNumberOfNotLoadedChildren,
loadChildren, loadChildren,
numberOfChildrenToLoad, loadBatchSize: numberOfChildrenToLoad,
} = useTreeViewContext(); } = useTreeViewContext();
const isExpandable = useMemo(() => getIsExpandable(node), [node, getIsExpandable]); const isExpandable = useMemo(() => getIsExpandable(node), [node, getIsExpandable]);
@ -73,13 +68,8 @@ export default function TreeNodeRenderer({ node, depth }: Props) {
const isRootNode = useMemo(() => getIsRootNode(node), [node, getIsRootNode]); const isRootNode = useMemo(() => getIsRootNode(node), [node, getIsRootNode]);
const numberOfNotLoadedChildren = useMemo( const childrenTotal = useMemo(() => getChildrenTotal(node), [node, getChildrenTotal]);
() => getNumberOfNotLoadedChildren(node), const childrenLength = useMemo(() => getChildrenLength(node), [node, getChildrenLength]);
[node, getNumberOfNotLoadedChildren],
);
const maxNumberOfChildrenToLoad = Math.min(numberOfNotLoadedChildren, numberOfChildrenToLoad);
const shouldShowLoadMoreButton = isExpanded && !isChildrenLoading && numberOfNotLoadedChildren > 0;
// Automatically select the current node if parent is selected if explicitlySelectChildren is not enabled // Automatically select the current node if parent is selected if explicitlySelectChildren is not enabled
// FYI: required to get loaded children selected if parent is selected // FYI: required to get loaded children selected if parent is selected
@ -90,7 +80,16 @@ export default function TreeNodeRenderer({ node, depth }: Props) {
}, [explicitlySelectChildren, hasParentNode, isParentSelected, select, node, isSelected, isSelectable]); }, [explicitlySelectChildren, hasParentNode, isParentSelected, select, node, isSelected, isSelectable]);
return ( return (
<> <NodesLoaderWrapper
trigger={loadingTriggerType}
onLoad={() => loadChildren(node)}
total={childrenTotal}
current={childrenLength}
pageSize={numberOfChildrenToLoad}
depth={depth + 1}
enabled={isExpanded}
loading={isChildrenLoading}
>
<Row> <Row>
<DepthMargin depth={depth} /> <DepthMargin depth={depth} />
@ -127,27 +126,8 @@ export default function TreeNodeRenderer({ node, depth }: Props) {
{/* Run loading on expand */} {/* Run loading on expand */}
{isExpanded && <ChildrenLoader node={node} />} {isExpanded && <ChildrenLoader node={node} />}
{/* Show more button */}
{shouldShowLoadMoreButton && (
<Row>
<DepthMargin depth={depth + 1} />
<ExpandToggler expandable={false} />
<Button onClick={() => loadChildren(node)} variant="link" color="gray">
Show {maxNumberOfChildrenToLoad} more
</Button>
</Row>
)}
{/* Loading indicator */} {/* Loading indicator */}
{isExpanded && isChildrenLoading && ( {isExpanded && isChildrenLoading && <TreeNodesViewLoader depth={depth + 1} />}
<Row> </NodesLoaderWrapper>
<DepthMargin depth={depth + 1} />
<ExpandToggler expandable={false} />
<LoaderWrapper>
<Loader size="xs" />
</LoaderWrapper>
</Row>
)}
</>
); );
} }

View File

@ -1,8 +1,11 @@
import React from 'react'; import React, { useCallback } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import TreeNodeRenderer from '@app/homeV3/modules/hierarchyViewModule/treeView/TreeNodeRenderer'; import TreeNodeRenderer from '@app/homeV3/modules/hierarchyViewModule/treeView/TreeNodeRenderer';
import TreeNodesViewLoader from '@app/homeV3/modules/hierarchyViewModule/treeView/components/TreeNodesViewLoader';
import NodesLoaderWrapper from '@app/homeV3/modules/hierarchyViewModule/treeView/components/itemsLoaderWrapper/NodesLoaderWrapper';
import useTreeViewContext from '@app/homeV3/modules/hierarchyViewModule/treeView/context/useTreeViewContext'; import useTreeViewContext from '@app/homeV3/modules/hierarchyViewModule/treeView/context/useTreeViewContext';
import { TreeNode } from '@app/homeV3/modules/hierarchyViewModule/treeView/types';
const Wrapper = styled.div` const Wrapper = styled.div`
display: flex; display: flex;
@ -17,14 +20,38 @@ const InlineBlockWrapper = styled.div<{ $hasExpanded: boolean }>`
`; `;
export default function TreeNodesRenderer() { export default function TreeNodesRenderer() {
const { nodes, hasAnyExpanded } = useTreeViewContext(); const {
nodes,
hasAnyExpanded,
loadBatchSize: numberOfChildrenToLoad,
loadRootNodes,
rootNodesLength,
rootNodesTotal,
rootNodesLoading,
loadingTriggerType,
} = useTreeViewContext();
const renderNode = useCallback((node: TreeNode) => <TreeNodeRenderer node={node} depth={0} key={node.value} />, []);
return ( return (
<InlineBlockWrapper $hasExpanded={hasAnyExpanded}> <InlineBlockWrapper $hasExpanded={hasAnyExpanded}>
<Wrapper> <Wrapper>
{nodes.map((node) => ( {loadRootNodes ? (
<TreeNodeRenderer node={node} depth={0} key={node.value} /> <NodesLoaderWrapper
))} trigger={loadingTriggerType}
total={rootNodesTotal}
current={rootNodesLength}
enabled={!rootNodesLoading}
depth={0}
onLoad={loadRootNodes}
pageSize={numberOfChildrenToLoad}
>
{nodes.map(renderNode)}
</NodesLoaderWrapper>
) : (
nodes.map(renderNode)
)}
{rootNodesLoading && <TreeNodesViewLoader depth={0} />}
</Wrapper> </Wrapper>
</InlineBlockWrapper> </InlineBlockWrapper>
); );

View File

@ -6,6 +6,7 @@ import {
flattenTreeNodes, flattenTreeNodes,
getAllParentValues, getAllParentValues,
getAllValues, getAllValues,
getOutOfTreeSelectedValues,
getTopLevelSelectedValuesFromTree, getTopLevelSelectedValuesFromTree,
getValueToTreeNodeMapping, getValueToTreeNodeMapping,
mergeTrees, mergeTrees,
@ -418,4 +419,56 @@ describe('treeView utils', () => {
expect(result).toEqual(['parent1', 'child2']); expect(result).toEqual(['parent1', 'child2']);
}); });
}); });
describe('getOutOfTreeSelectedValues', () => {
const child1 = createTreeNode('child1');
const child2 = createTreeNode('child2');
const parent1 = createTreeNode('parent1', 'parent1', [child1]);
const parent2 = createTreeNode('parent2', 'parent2', [child2]);
const tree = [parent1, parent2];
it('should return only values out of tree', () => {
const selectedValues = ['parent1', 'child2', 'parent3'];
const result = getOutOfTreeSelectedValues(selectedValues, tree);
expect(result).toEqual(['parent3']);
});
it('should handle empty tree', () => {
const selectedValues = ['parent1', 'child2', 'parent3'];
const result = getOutOfTreeSelectedValues(selectedValues, []);
expect(result).toEqual(selectedValues);
});
it('should handle empty selected values', () => {
const result = getOutOfTreeSelectedValues([], tree);
expect(result).toEqual([]);
});
it('should handle empty selected values and tree', () => {
const result = getOutOfTreeSelectedValues([], []);
expect(result).toEqual([]);
});
it('should return empty array when all values included in tree', () => {
const selectedValues = ['parent1', 'child2'];
const result = getOutOfTreeSelectedValues(selectedValues, tree);
expect(result).toEqual([]);
});
it('should return empty array when all values not included in tree', () => {
const selectedValues = ['parent3'];
const result = getOutOfTreeSelectedValues(selectedValues, tree);
expect(result).toEqual(['parent3']);
});
});
}); });

View File

@ -0,0 +1,11 @@
import styled from 'styled-components';
const Row = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
`;
export default Row;

View File

@ -0,0 +1,25 @@
import { Loader } from '@components';
import React from 'react';
import styled from 'styled-components';
import DepthMargin from '@app/homeV3/modules/hierarchyViewModule/treeView/DepthMargin';
import ExpandToggler from '@app/homeV3/modules/hierarchyViewModule/treeView/ExpandToggler';
import Row from '@app/homeV3/modules/hierarchyViewModule/treeView/components/Row';
const LoaderWrapper = styled.div``;
interface Props {
depth: number;
}
export default function TreeNodesViewLoader({ depth }: Props) {
return (
<Row>
<DepthMargin depth={depth + 1} />
<ExpandToggler expandable={false} />
<LoaderWrapper>
<Loader size="xs" />
</LoaderWrapper>
</Row>
);
}

View File

@ -0,0 +1,36 @@
import { Button } from '@components';
import React, { useMemo } from 'react';
import DepthMargin from '@app/homeV3/modules/hierarchyViewModule/treeView/DepthMargin';
import ExpandToggler from '@app/homeV3/modules/hierarchyViewModule/treeView/ExpandToggler';
import Row from '@app/homeV3/modules/hierarchyViewModule/treeView/components/Row';
import { NodesLoaderWrapperProps } from '@app/homeV3/modules/hierarchyViewModule/treeView/components/itemsLoaderWrapper/types';
export default function NodesLoaderByButtonWrapper({
children,
total,
current,
pageSize,
depth,
enabled,
loading,
onLoad,
}: React.PropsWithChildren<NodesLoaderWrapperProps>) {
const loadMoreNumber = useMemo(() => Math.min(total - current, pageSize), [total, current, pageSize]);
return (
<>
{children}
{enabled && !loading && loadMoreNumber > 0 && (
<Row>
<DepthMargin depth={depth + 1} />
<ExpandToggler expandable={false} />
<Button onClick={onLoad} variant="link" color="gray">
Show {loadMoreNumber} more
</Button>
</Row>
)}
</>
);
}

View File

@ -0,0 +1,45 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import styled from 'styled-components';
import { NodesLoaderWrapperProps } from '@app/homeV3/modules/hierarchyViewModule/treeView/components/itemsLoaderWrapper/types';
export const ObserverContainer = styled.div`
height: 1px;
margin-top: 1px;
`;
export default function NodesLoaderInfiniteScrollWrapper({
children,
total,
current,
enabled,
loading,
onLoad,
}: React.PropsWithChildren<NodesLoaderWrapperProps>) {
const [scrollRef, inView] = useInView();
// FYI: additional flag to prevent `onLoad` calling
// when infinite scroll triggered but real loading hasn't started yet
const [shouldPreventLoading, setShouldPreventLoading] = useState<boolean>(false);
// reset flag when observer container is out of view (it's hidden while real loading because `loading` is true)
useEffect(() => {
if (!inView) setShouldPreventLoading(false);
}, [inView]);
const hasMoreNodes = useMemo(() => total - current > 0, [total, current]);
useEffect(() => {
if (enabled && inView && hasMoreNodes && !shouldPreventLoading) {
setShouldPreventLoading(true);
onLoad();
}
}, [shouldPreventLoading, inView, enabled, hasMoreNodes, onLoad]);
return (
<>
{children}
{enabled && !loading && hasMoreNodes && <ObserverContainer ref={scrollRef} />}
</>
);
}

View File

@ -0,0 +1,26 @@
import React, { useMemo } from 'react';
import NodesLoaderByButtonWrapper from '@app/homeV3/modules/hierarchyViewModule/treeView/components/itemsLoaderWrapper/NodesLoaderByButtonWrapper';
import NodesLoaderInfiniteScrollWrapper from '@app/homeV3/modules/hierarchyViewModule/treeView/components/itemsLoaderWrapper/NodesLoaderInfiniteScrollWrapper';
import { NodesLoaderWrapperProps } from '@app/homeV3/modules/hierarchyViewModule/treeView/components/itemsLoaderWrapper/types';
import { LoadingTriggerType } from '@app/homeV3/modules/hierarchyViewModule/treeView/types';
interface Props extends NodesLoaderWrapperProps {
trigger?: LoadingTriggerType;
loading?: boolean;
}
export default function NodesLoaderWrapper({ children, trigger, ...props }: React.PropsWithChildren<Props>) {
const NodesLoaderWrapperComponent = useMemo(() => {
switch (trigger) {
case 'button':
return NodesLoaderByButtonWrapper;
case 'infiniteScroll':
return NodesLoaderInfiniteScrollWrapper;
default:
return NodesLoaderByButtonWrapper;
}
}, [trigger]);
return <NodesLoaderWrapperComponent {...props}>{children}</NodesLoaderWrapperComponent>;
}

View File

@ -0,0 +1,10 @@
export interface NodesLoaderWrapperProps {
total: number;
// how many items already loaded
current: number;
pageSize: number;
depth: number;
enabled?: boolean;
loading?: boolean;
onLoad: () => void;
}

View File

@ -1 +1 @@
export const DEFAULT_NUMBER_OF_CHILDREN_TO_LOAD = 5; export const DEFAULT_LOAD_BATCH_SIZE = 5;

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { DEFAULT_NUMBER_OF_CHILDREN_TO_LOAD } from '@app/homeV3/modules/hierarchyViewModule/treeView/constants'; import { DEFAULT_LOAD_BATCH_SIZE } from '@app/homeV3/modules/hierarchyViewModule/treeView/constants';
import { TreeViewContextType } from '@app/homeV3/modules/hierarchyViewModule/treeView/types'; import { TreeViewContextType } from '@app/homeV3/modules/hierarchyViewModule/treeView/types';
const DEFAULT_TREE_VIEW_CONTEXT: TreeViewContextType = { const DEFAULT_TREE_VIEW_CONTEXT: TreeViewContextType = {
@ -8,6 +8,10 @@ const DEFAULT_TREE_VIEW_CONTEXT: TreeViewContextType = {
getHasParentNode: () => false, getHasParentNode: () => false,
getIsRootNode: () => false, getIsRootNode: () => false,
rootNodesLength: 0,
rootNodesTotal: 0,
getChildrenLength: () => 0,
getChildrenTotal: () => 0,
getIsExpandable: () => false, getIsExpandable: () => false,
getIsExpanded: () => false, getIsExpanded: () => false,
@ -26,9 +30,8 @@ const DEFAULT_TREE_VIEW_CONTEXT: TreeViewContextType = {
toggleSelected: () => {}, toggleSelected: () => {},
getIsChildrenLoading: () => false, getIsChildrenLoading: () => false,
getNumberOfNotLoadedChildren: () => 0,
loadChildren: () => {}, loadChildren: () => {},
numberOfChildrenToLoad: DEFAULT_NUMBER_OF_CHILDREN_TO_LOAD, loadBatchSize: DEFAULT_LOAD_BATCH_SIZE,
explicitlySelectChildren: false, explicitlySelectChildren: false,
explicitlyUnselectChildren: false, explicitlyUnselectChildren: false,

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { DEFAULT_NUMBER_OF_CHILDREN_TO_LOAD } from '@app/homeV3/modules/hierarchyViewModule/treeView/constants'; import { DEFAULT_LOAD_BATCH_SIZE } from '@app/homeV3/modules/hierarchyViewModule/treeView/constants';
import TreeViewContext from '@app/homeV3/modules/hierarchyViewModule/treeView/context/TreeViewContext'; import TreeViewContext from '@app/homeV3/modules/hierarchyViewModule/treeView/context/TreeViewContext';
import { TreeNode, TreeViewContextProviderProps } from '@app/homeV3/modules/hierarchyViewModule/treeView/types'; import { TreeNode, TreeViewContextProviderProps } from '@app/homeV3/modules/hierarchyViewModule/treeView/types';
import { import {
@ -22,6 +22,10 @@ export default function TreeViewContextProvider({
selectable, selectable,
updateSelectedValues, updateSelectedValues,
expandParentNodesOfInitialSelectedValues, expandParentNodesOfInitialSelectedValues,
loadingTriggerType = 'button',
rootNodesTotal: rootNodesTotalProperty,
loadRootNodes,
rootNodesLoading,
loadChildren: loadAsyncChildren, loadChildren: loadAsyncChildren,
renderNodeLabel, renderNodeLabel,
explicitlySelectChildren, explicitlySelectChildren,
@ -29,7 +33,7 @@ export default function TreeViewContextProvider({
explicitlySelectParent, explicitlySelectParent,
explicitlyUnselectParent, explicitlyUnselectParent,
enableIntermediateSelectState, enableIntermediateSelectState,
numberOfChildrenToLoad = DEFAULT_NUMBER_OF_CHILDREN_TO_LOAD, loadBatchSize = DEFAULT_LOAD_BATCH_SIZE,
}: React.PropsWithChildren<TreeViewContextProviderProps>) { }: React.PropsWithChildren<TreeViewContextProviderProps>) {
const [internalExpandedValues, setInternalExpandedValues] = useState<string[]>(expandedValues ?? []); const [internalExpandedValues, setInternalExpandedValues] = useState<string[]>(expandedValues ?? []);
const [isExpandedValuesInitialized, setIsExpandedValuesInitialized] = useState<boolean>(false); const [isExpandedValuesInitialized, setIsExpandedValuesInitialized] = useState<boolean>(false);
@ -92,6 +96,18 @@ export default function TreeViewContextProvider({
[getHasParentNode, getRootNodes, valueToTreeNodeMap], [getHasParentNode, getRootNodes, valueToTreeNodeMap],
); );
const rootNodesLength = useMemo(() => nodes.length, [nodes]);
const rootNodesTotal = useMemo(
() => rootNodesTotalProperty ?? rootNodesLength,
[rootNodesTotalProperty, rootNodesLength],
);
const getChildrenLength = useCallback((node: TreeNode) => node.children?.length ?? 0, []);
const getChildrenTotal = useCallback(
(node: TreeNode) => node.totalChildren ?? getChildrenLength(node),
[getChildrenLength],
);
// Expanding // Expanding
const getIsExpandable = useCallback((node: TreeNode) => !!node.children?.length || !!node.hasAsyncChildren, []); const getIsExpandable = useCallback((node: TreeNode) => !!node.children?.length || !!node.hasAsyncChildren, []);
@ -258,17 +274,18 @@ export default function TreeViewContextProvider({
// Loading of children // Loading of children
const getIsChildrenLoading = useCallback((node: TreeNode) => !!node.isChildrenLoading, []); const getIsChildrenLoading = useCallback((node: TreeNode) => !!node.isChildrenLoading, []);
const getNumberOfNotLoadedChildren = useCallback(
(node: TreeNode) => (node.totalChildren ? node.totalChildren - (node.children?.length ?? 0) : 0),
[],
);
return ( return (
<TreeViewContext.Provider <TreeViewContext.Provider
value={{ value={{
nodes: preprocessedNodes, nodes: preprocessedNodes,
// Node utils
getHasParentNode, getHasParentNode,
getIsRootNode, getIsRootNode,
rootNodesLength,
rootNodesTotal,
getChildrenLength,
getChildrenTotal,
// Expanding // Expanding
getIsExpandable, getIsExpandable,
@ -293,11 +310,13 @@ export default function TreeViewContextProvider({
explicitlyUnselectParent, explicitlyUnselectParent,
enableIntermediateSelectState, enableIntermediateSelectState,
loadingTriggerType,
loadRootNodes,
rootNodesLoading,
// Async loading of children // Async loading of children
getIsChildrenLoading, getIsChildrenLoading,
getNumberOfNotLoadedChildren,
loadChildren, loadChildren,
numberOfChildrenToLoad, loadBatchSize,
renderNodeLabel, renderNodeLabel,
}} }}

View File

@ -24,11 +24,17 @@ export interface TreeNodeProps {
depth: number; depth: number;
} }
export interface TreeViewContextType { export type LoadingTriggerType = 'button' | 'infiniteScroll';
nodes: TreeNode[];
export interface TreeViewContextType {
// Tree/node utils and params
nodes: TreeNode[];
getHasParentNode: (node: TreeNode) => boolean; getHasParentNode: (node: TreeNode) => boolean;
getIsRootNode: (node: TreeNode) => boolean; getIsRootNode: (node: TreeNode) => boolean;
rootNodesLength: number;
rootNodesTotal: number;
getChildrenLength: (node: TreeNode) => number;
getChildrenTotal: (node: TreeNode) => number;
// Expand // Expand
getIsExpandable: (node: TreeNode) => boolean; getIsExpandable: (node: TreeNode) => boolean;
@ -54,13 +60,18 @@ export interface TreeViewContextType {
explicitlyUnselectParent?: boolean; explicitlyUnselectParent?: boolean;
enableIntermediateSelectState?: boolean; enableIntermediateSelectState?: boolean;
// Async loading of children // Async loading
// ------------------------------------------------- // -------------------------------------------------
loadingTriggerType?: LoadingTriggerType;
// Optional loading of root nodes
loadRootNodes?: () => void;
hasMoreRootNodes?: boolean;
rootNodesLoading?: boolean;
getIsChildrenLoading: (node: TreeNode) => boolean; getIsChildrenLoading: (node: TreeNode) => boolean;
getNumberOfNotLoadedChildren: (node: TreeNode) => number;
loadChildren: (node: TreeNode) => void; loadChildren: (node: TreeNode) => void;
// Max number of children to load per each loadChildren call // Max number of children to load per each loadChildren call
numberOfChildrenToLoad: number; loadBatchSize: number;
// Optional custom node label renderer // Optional custom node label renderer
renderNodeLabel?: (props: TreeNodeProps) => React.ReactNode; renderNodeLabel?: (props: TreeNodeProps) => React.ReactNode;
@ -104,7 +115,13 @@ export interface TreeViewContextProviderProps {
renderNodeLabel?: (props: TreeNodeProps) => React.ReactNode; renderNodeLabel?: (props: TreeNodeProps) => React.ReactNode;
// Async // Async
// Optional pagination/loading of root nodes
loadingTriggerType?: LoadingTriggerType;
rootNodesTotal?: number;
loadRootNodes?: () => void;
hasMoreRootNodes?: boolean;
rootNodesLoading?: boolean;
// Callback to load children of a specific node // Callback to load children of a specific node
loadChildren?: (node: TreeNode) => void; loadChildren?: (node: TreeNode) => void;
numberOfChildrenToLoad?: number; loadBatchSize?: number;
} }

View File

@ -192,3 +192,8 @@ export function sortTree(tree: TreeNode[], nodesSorter: (nodes: TreeNode[]) => T
return nodesSorter(tree).map(traverse); return nodesSorter(tree).map(traverse);
} }
export function getOutOfTreeSelectedValues(selectedValues: string[], tree: TreeNode[]): string[] {
const allTreeValues = getAllValues(tree);
return selectedValues.filter((value) => !allTreeValues.includes(value));
}