Update Domains state management to improve scaling with many Domains (#14478)

This commit is contained in:
Chris Collins 2025-08-21 12:21:01 -04:00 committed by GitHub
parent a79b542d65
commit 2cad5ddcb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1669 additions and 173 deletions

View File

@ -62,6 +62,7 @@ export const updateListDomainsCache = (
addToListDomainsCache(
client,
{
__typename: 'Domain',
urn,
id: id || null,
type: EntityType.Domain,
@ -75,6 +76,8 @@ export const updateListDomainsCache = (
dataProducts: null,
parentDomains: null,
displayProperties: null,
institutionalMemory: null,
applications: null,
},
1000,
parentDomain,

View File

@ -3,7 +3,7 @@ import React, { useState } from 'react';
import styled from 'styled-components';
import analytics, { EventType } from '@app/analytics';
import { useDomainsContext as useDomainsContextV2 } from '@app/domainV2/DomainsContext';
import { UpdatedDomain, useDomainsContext as useDomainsContextV2 } from '@app/domainV2/DomainsContext';
import DomainParentSelect from '@app/entityV2/shared/EntityDropdown/DomainParentSelect';
import { ModalButtonContainer } from '@app/shared/button/styledComponents';
import { validateCustomUrnId } from '@app/shared/textUtil';
@ -12,6 +12,7 @@ import { useIsNestedDomainsEnabled } from '@app/useAppConfig';
import { Button } from '@src/alchemy-components';
import { useCreateDomainMutation } from '@graphql/domain.generated';
import { EntityType } from '@types';
const SuggestedNamesGroup = styled.div`
margin-top: 8px;
@ -48,7 +49,7 @@ const AdvancedLabel = styled(Typography.Text)`
type Props = {
onClose: () => void;
onCreate: (
onCreate?: (
urn: string,
id: string | undefined,
name: string,
@ -66,7 +67,7 @@ const DESCRIPTION_FIELD_NAME = 'description';
export default function CreateDomainModal({ onClose, onCreate }: Props) {
const isNestedDomainsEnabled = useIsNestedDomainsEnabled();
const [createDomainMutation] = useCreateDomainMutation();
const { entityData } = useDomainsContextV2();
const { entityData, setNewDomain } = useDomainsContextV2();
const [selectedParentUrn, setSelectedParentUrn] = useState<string>(
(isNestedDomainsEnabled && entityData?.urn) || '',
);
@ -94,13 +95,24 @@ export default function CreateDomainModal({ onClose, onCreate }: Props) {
content: `Created domain!`,
duration: 3,
});
onCreate(
onCreate?.(
data?.createDomain || '',
form.getFieldValue(ID_FIELD_NAME),
form.getFieldValue(NAME_FIELD_NAME),
form.getFieldValue(DESCRIPTION_FIELD_NAME),
selectedParentUrn || undefined,
);
const newDomain: UpdatedDomain = {
urn: data?.createDomain || '',
type: EntityType.Domain,
id: form.getFieldValue(ID_FIELD_NAME),
properties: {
name: form.getFieldValue(NAME_FIELD_NAME),
description: form.getFieldValue(DESCRIPTION_FIELD_NAME),
},
parentDomain: selectedParentUrn || undefined,
};
setNewDomain(newDomain);
form.resetFields();
}
})

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Route, Switch, matchPath, useLocation } from 'react-router-dom';
import styled from 'styled-components/macro';
import { DomainsContext } from '@app/domainV2/DomainsContext';
import { DomainsContext, UpdatedDomain } from '@app/domainV2/DomainsContext';
import ManageDomainsPageV2 from '@app/domainV2/nestedDomains/ManageDomainsPageV2';
import ManageDomainsSidebar from '@app/domainV2/nestedDomains/ManageDomainsSidebar';
import { EntityPage } from '@app/entity/EntityPage';
@ -26,6 +26,9 @@ const ContentWrapper = styled.div<{ $isShowNavBarRedesign?: boolean; $isEntityPr
export default function DomainRoutes() {
const entityRegistry = useEntityRegistry();
const [entityData, setEntityData] = useState<GenericEntityProperties | null>(null);
const [newDomain, setNewDomain] = useState<UpdatedDomain | null>(null);
const [deletedDomain, setDeletedDomain] = useState<UpdatedDomain | null>(null);
const [updatedDomain, setUpdatedDomain] = useState<UpdatedDomain | null>(null);
const [isSidebarClosed, setIsSidebarClosed] = useState(true);
const entitySidebarWidth = useSidebarWidth();
const isShowNavBarRedesign = useShowNavBarRedesign();
@ -35,7 +38,18 @@ export default function DomainRoutes() {
matchPath(location.pathname, `/${entityRegistry.getPathName(EntityType.Domain)}/:urn`) !== null;
return (
<DomainsContext.Provider value={{ entityData, setEntityData }}>
<DomainsContext.Provider
value={{
entityData,
setEntityData,
newDomain,
setNewDomain,
deletedDomain,
setDeletedDomain,
updatedDomain,
setUpdatedDomain,
}}
>
<ContentWrapper $isShowNavBarRedesign={isShowNavBarRedesign} $isEntityProfile={isEntityProfile}>
<ManageDomainsSidebar isEntityProfile={isEntityProfile} />
<Switch>

View File

@ -2,17 +2,32 @@ import React, { useContext } from 'react';
import { GenericEntityProperties } from '@app/entity/shared/types';
import { ListDomainFragment } from '@graphql/domain.generated';
export type UpdatedDomain = ListDomainFragment & { parentDomain?: string };
export interface DomainsContextType {
entityData: GenericEntityProperties | null;
setEntityData: (entityData: GenericEntityProperties | null) => void;
newDomain: UpdatedDomain | null;
setNewDomain: (newDomain: UpdatedDomain | null) => void;
deletedDomain: UpdatedDomain | null;
setDeletedDomain: (newDomain: UpdatedDomain | null) => void;
updatedDomain: UpdatedDomain | null;
setUpdatedDomain: (newDomain: UpdatedDomain | null) => void;
}
export const DomainsContext = React.createContext<DomainsContextType>({
entityData: null,
setEntityData: () => {},
newDomain: null,
setNewDomain: () => {},
deletedDomain: null,
setDeletedDomain: () => {},
updatedDomain: null,
setUpdatedDomain: () => {},
});
export const useDomainsContext = () => {
const { entityData, setEntityData } = useContext(DomainsContext);
return { entityData, setEntityData };
return useContext(DomainsContext);
};

View File

@ -2,7 +2,7 @@ import { Typography } from 'antd';
import React, { useState } from 'react';
import styled from 'styled-components';
import { DomainsContext } from '@app/domainV2/DomainsContext';
import { DomainsContext, UpdatedDomain } from '@app/domainV2/DomainsContext';
import { DomainsList } from '@app/domainV2/DomainsList';
import { GenericEntityProperties } from '@app/entity/shared/types';
@ -26,9 +26,23 @@ const ListContainer = styled.div``;
export const ManageDomainsPage = () => {
const [entityData, setEntityData] = useState<GenericEntityProperties | null>(null);
const [newDomain, setNewDomain] = useState<UpdatedDomain | null>(null);
const [deletedDomain, setDeletedDomain] = useState<UpdatedDomain | null>(null);
const [updatedDomain, setUpdatedDomain] = useState<UpdatedDomain | null>(null);
return (
<DomainsContext.Provider value={{ entityData, setEntityData }}>
<DomainsContext.Provider
value={{
entityData,
setEntityData,
newDomain,
setNewDomain,
deletedDomain,
setDeletedDomain,
updatedDomain,
setUpdatedDomain,
}}
>
<PageContainer>
<PageHeaderContainer>
<PageTitle level={3}>Domains</PageTitle>

View File

@ -0,0 +1,307 @@
import { fireEvent, render } from '@testing-library/react';
import React, { useState } from 'react';
import { DomainsContext, UpdatedDomain, useDomainsContext } from '@app/domainV2/DomainsContext';
import { GenericEntityProperties } from '@app/entity/shared/types';
import { EntityType } from '@types';
// Mock domain for testing
const createMockDomain = (urn: string, name: string): UpdatedDomain => ({
urn,
id: urn,
type: EntityType.Domain,
properties: {
name,
description: `Description for ${name}`,
},
ownership: null,
entities: null,
children: { total: 0 },
dataProducts: null,
parentDomains: null,
displayProperties: null,
});
// Test component that uses the context
const TestComponent = () => {
const {
entityData,
setEntityData,
newDomain,
setNewDomain,
deletedDomain,
setDeletedDomain,
updatedDomain,
setUpdatedDomain,
} = useDomainsContext();
return (
<div>
<div data-testid="entity-data">{entityData ? JSON.stringify(entityData) : 'null'}</div>
<div data-testid="new-domain">{newDomain ? newDomain.urn : 'null'}</div>
<div data-testid="deleted-domain">{deletedDomain ? deletedDomain.urn : 'null'}</div>
<div data-testid="updated-domain">{updatedDomain ? updatedDomain.urn : 'null'}</div>
<button
type="button"
data-testid="set-entity-data"
onClick={() => setEntityData({ urn: 'test-urn', type: EntityType.Domain })}
>
Set Entity Data
</button>
<button
type="button"
data-testid="set-new-domain"
onClick={() => setNewDomain(createMockDomain('urn:li:domain:new', 'New Domain'))}
>
Set New Domain
</button>
<button
type="button"
data-testid="set-deleted-domain"
onClick={() => setDeletedDomain(createMockDomain('urn:li:domain:deleted', 'Deleted Domain'))}
>
Set Deleted Domain
</button>
<button
type="button"
data-testid="set-updated-domain"
onClick={() => setUpdatedDomain(createMockDomain('urn:li:domain:updated', 'Updated Domain'))}
>
Set Updated Domain
</button>
<button
type="button"
data-testid="clear-all"
onClick={() => {
setEntityData(null);
setNewDomain(null);
setDeletedDomain(null);
setUpdatedDomain(null);
}}
>
Clear All
</button>
</div>
);
};
// Test provider component that manages context state
const TestProvider = ({ children }: { children: React.ReactNode }) => {
const [entityData, setEntityData] = useState<GenericEntityProperties | null>(null);
const [newDomain, setNewDomain] = useState<UpdatedDomain | null>(null);
const [deletedDomain, setDeletedDomain] = useState<UpdatedDomain | null>(null);
const [updatedDomain, setUpdatedDomain] = useState<UpdatedDomain | null>(null);
return (
<DomainsContext.Provider
value={{
entityData,
setEntityData,
newDomain,
setNewDomain,
deletedDomain,
setDeletedDomain,
updatedDomain,
setUpdatedDomain,
}}
>
{children}
</DomainsContext.Provider>
);
};
describe('DomainsContext', () => {
describe('Context Provider', () => {
it('should provide default values when no provider is used', () => {
const { getByTestId } = render(<TestComponent />);
expect(getByTestId('entity-data')).toHaveTextContent('null');
expect(getByTestId('new-domain')).toHaveTextContent('null');
expect(getByTestId('deleted-domain')).toHaveTextContent('null');
expect(getByTestId('updated-domain')).toHaveTextContent('null');
});
it('should allow setting and getting entityData', () => {
const { getByTestId } = render(
<TestProvider>
<TestComponent />
</TestProvider>,
);
expect(getByTestId('entity-data')).toHaveTextContent('null');
fireEvent.click(getByTestId('set-entity-data'));
expect(getByTestId('entity-data')).toHaveTextContent(
JSON.stringify({ urn: 'test-urn', type: EntityType.Domain }),
);
});
it('should allow setting and getting newDomain', () => {
const { getByTestId } = render(
<TestProvider>
<TestComponent />
</TestProvider>,
);
expect(getByTestId('new-domain')).toHaveTextContent('null');
fireEvent.click(getByTestId('set-new-domain'));
expect(getByTestId('new-domain')).toHaveTextContent('urn:li:domain:new');
});
it('should allow setting and getting deletedDomain', () => {
const { getByTestId } = render(
<TestProvider>
<TestComponent />
</TestProvider>,
);
expect(getByTestId('deleted-domain')).toHaveTextContent('null');
fireEvent.click(getByTestId('set-deleted-domain'));
expect(getByTestId('deleted-domain')).toHaveTextContent('urn:li:domain:deleted');
});
it('should allow setting and getting updatedDomain', () => {
const { getByTestId } = render(
<TestProvider>
<TestComponent />
</TestProvider>,
);
expect(getByTestId('updated-domain')).toHaveTextContent('null');
fireEvent.click(getByTestId('set-updated-domain'));
expect(getByTestId('updated-domain')).toHaveTextContent('urn:li:domain:updated');
});
it('should allow clearing all context values', () => {
const { getByTestId } = render(
<TestProvider>
<TestComponent />
</TestProvider>,
);
// Set all values first
fireEvent.click(getByTestId('set-entity-data'));
fireEvent.click(getByTestId('set-new-domain'));
fireEvent.click(getByTestId('set-deleted-domain'));
fireEvent.click(getByTestId('set-updated-domain'));
// Verify they are set
expect(getByTestId('entity-data')).not.toHaveTextContent('null');
expect(getByTestId('new-domain')).not.toHaveTextContent('null');
expect(getByTestId('deleted-domain')).not.toHaveTextContent('null');
expect(getByTestId('updated-domain')).not.toHaveTextContent('null');
// Clear all
fireEvent.click(getByTestId('clear-all'));
// Verify they are cleared
expect(getByTestId('entity-data')).toHaveTextContent('null');
expect(getByTestId('new-domain')).toHaveTextContent('null');
expect(getByTestId('deleted-domain')).toHaveTextContent('null');
expect(getByTestId('updated-domain')).toHaveTextContent('null');
});
});
describe('useDomainsContext hook', () => {
it('should return all context values and setters', () => {
let contextValues: any;
const TestComponent2 = () => {
contextValues = useDomainsContext();
return <div />;
};
render(
<TestProvider>
<TestComponent2 />
</TestProvider>,
);
expect(contextValues).toHaveProperty('entityData');
expect(contextValues).toHaveProperty('setEntityData');
expect(contextValues).toHaveProperty('newDomain');
expect(contextValues).toHaveProperty('setNewDomain');
expect(contextValues).toHaveProperty('deletedDomain');
expect(contextValues).toHaveProperty('setDeletedDomain');
expect(contextValues).toHaveProperty('updatedDomain');
expect(contextValues).toHaveProperty('setUpdatedDomain');
// Verify setters are functions
expect(typeof contextValues.setEntityData).toBe('function');
expect(typeof contextValues.setNewDomain).toBe('function');
expect(typeof contextValues.setDeletedDomain).toBe('function');
expect(typeof contextValues.setUpdatedDomain).toBe('function');
});
it('should maintain separate state for different values', () => {
const { getByTestId } = render(
<TestProvider>
<TestComponent />
</TestProvider>,
);
// Set new domain
fireEvent.click(getByTestId('set-new-domain'));
expect(getByTestId('new-domain')).toHaveTextContent('urn:li:domain:new');
expect(getByTestId('deleted-domain')).toHaveTextContent('null');
expect(getByTestId('updated-domain')).toHaveTextContent('null');
// Set deleted domain
fireEvent.click(getByTestId('set-deleted-domain'));
expect(getByTestId('new-domain')).toHaveTextContent('urn:li:domain:new');
expect(getByTestId('deleted-domain')).toHaveTextContent('urn:li:domain:deleted');
expect(getByTestId('updated-domain')).toHaveTextContent('null');
// Set updated domain
fireEvent.click(getByTestId('set-updated-domain'));
expect(getByTestId('new-domain')).toHaveTextContent('urn:li:domain:new');
expect(getByTestId('deleted-domain')).toHaveTextContent('urn:li:domain:deleted');
expect(getByTestId('updated-domain')).toHaveTextContent('urn:li:domain:updated');
});
});
describe('UpdatedDomain type', () => {
it('should work with domains that have parentDomain property', () => {
const domainWithParent = createMockDomain('urn:li:domain:child', 'Child Domain');
domainWithParent.parentDomain = 'urn:li:domain:parent';
const TestComponentWithParent = () => {
const { newDomain, setNewDomain } = useDomainsContext();
return (
<div>
<div data-testid="domain-parent">{newDomain?.parentDomain || 'null'}</div>
<button
type="button"
data-testid="set-domain-with-parent"
onClick={() => setNewDomain(domainWithParent)}
>
Set Domain With Parent
</button>
</div>
);
};
const { getByTestId } = render(
<TestProvider>
<TestComponentWithParent />
</TestProvider>,
);
expect(getByTestId('domain-parent')).toHaveTextContent('null');
fireEvent.click(getByTestId('set-domain-with-parent'));
expect(getByTestId('domain-parent')).toHaveTextContent('urn:li:domain:parent');
});
});
});

View File

@ -0,0 +1,227 @@
import { DOMAIN_COUNT, getDomainsScrollInput } from '@app/domainV2/useScrollDomains';
import { ENTITY_NAME_FIELD } from '@app/searchV2/context/constants';
import { EntityType, FilterOperator, SortOrder } from '@types';
describe('getDomainsScrollInput', () => {
describe('Root domains (parentDomain is null)', () => {
it('should create correct input for root domains with no scrollId', () => {
const result = getDomainsScrollInput(null, null);
expect(result).toEqual({
input: {
scrollId: null,
query: '*',
types: [EntityType.Domain],
orFilters: [
{
and: [
{
field: 'parentDomain',
condition: FilterOperator.Exists,
negated: true,
},
],
},
],
count: DOMAIN_COUNT,
sortInput: {
sortCriteria: [
{
field: ENTITY_NAME_FIELD,
sortOrder: SortOrder.Ascending,
},
],
},
searchFlags: { skipCache: true },
},
});
});
it('should create correct input for root domains with scrollId', () => {
const scrollId = 'test-scroll-id-123';
const result = getDomainsScrollInput(null, scrollId);
expect(result).toEqual({
input: {
scrollId,
query: '*',
types: [EntityType.Domain],
orFilters: [
{
and: [
{
field: 'parentDomain',
condition: FilterOperator.Exists,
negated: true,
},
],
},
],
count: DOMAIN_COUNT,
sortInput: {
sortCriteria: [
{
field: ENTITY_NAME_FIELD,
sortOrder: SortOrder.Ascending,
},
],
},
searchFlags: { skipCache: true },
},
});
});
});
describe('Child domains (parentDomain is provided)', () => {
const parentDomainUrn = 'urn:li:domain:parent';
it('should create correct input for child domains with no scrollId', () => {
const result = getDomainsScrollInput(parentDomainUrn, null);
expect(result).toEqual({
input: {
scrollId: null,
query: '*',
types: [EntityType.Domain],
orFilters: [
{
and: [
{
field: 'parentDomain',
values: [parentDomainUrn],
},
],
},
],
count: DOMAIN_COUNT,
sortInput: {
sortCriteria: [
{
field: ENTITY_NAME_FIELD,
sortOrder: SortOrder.Ascending,
},
],
},
searchFlags: { skipCache: true },
},
});
});
it('should create correct input for child domains with scrollId', () => {
const scrollId = 'child-scroll-id-456';
const result = getDomainsScrollInput(parentDomainUrn, scrollId);
expect(result).toEqual({
input: {
scrollId,
query: '*',
types: [EntityType.Domain],
orFilters: [
{
and: [
{
field: 'parentDomain',
values: [parentDomainUrn],
},
],
},
],
count: DOMAIN_COUNT,
sortInput: {
sortCriteria: [
{
field: ENTITY_NAME_FIELD,
sortOrder: SortOrder.Ascending,
},
],
},
searchFlags: { skipCache: true },
},
});
});
});
describe('Edge cases', () => {
it('should handle empty string parentDomain as no parent domain', () => {
const result = getDomainsScrollInput('', null);
expect(result.input.orFilters).toEqual([
{
and: [
{
field: 'parentDomain',
condition: FilterOperator.Exists,
negated: true,
},
],
},
]);
});
it('should handle empty string scrollId', () => {
const result = getDomainsScrollInput(null, '');
expect(result.input.scrollId).toBe('');
});
it('should handle undefined parentDomain same as null', () => {
const resultNull = getDomainsScrollInput(null, null);
const resultUndefined = getDomainsScrollInput(undefined as any, null);
expect(resultNull).toEqual(resultUndefined);
});
});
describe('Configuration consistency', () => {
it('should always use the same query string', () => {
const result1 = getDomainsScrollInput(null, null);
const result2 = getDomainsScrollInput('urn:li:domain:parent', 'scroll-id');
expect(result1.input.query).toBe('*');
expect(result2.input.query).toBe('*');
});
it('should always include Domain entity type', () => {
const result1 = getDomainsScrollInput(null, null);
const result2 = getDomainsScrollInput('urn:li:domain:parent', 'scroll-id');
expect(result1.input.types).toEqual([EntityType.Domain]);
expect(result2.input.types).toEqual([EntityType.Domain]);
});
it('should always use the same count', () => {
const result1 = getDomainsScrollInput(null, null);
const result2 = getDomainsScrollInput('urn:li:domain:parent', 'scroll-id');
expect(result1.input.count).toBe(DOMAIN_COUNT);
expect(result2.input.count).toBe(DOMAIN_COUNT);
expect(result1.input.count).toBe(25); // Verify the constant value
});
it('should always use ascending sort by name', () => {
const result1 = getDomainsScrollInput(null, null);
const result2 = getDomainsScrollInput('urn:li:domain:parent', 'scroll-id');
const expectedSortInput = {
sortCriteria: [
{
field: ENTITY_NAME_FIELD,
sortOrder: SortOrder.Ascending,
},
],
};
expect(result1.input.sortInput).toEqual(expectedSortInput);
expect(result2.input.sortInput).toEqual(expectedSortInput);
});
it('should always skip cache', () => {
const result1 = getDomainsScrollInput(null, null);
const result2 = getDomainsScrollInput('urn:li:domain:parent', 'scroll-id');
expect(result1.input.searchFlags).toEqual({ skipCache: true });
expect(result2.input.searchFlags).toEqual({ skipCache: true });
});
});
});

View File

@ -0,0 +1,168 @@
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { DomainsContext } from '@app/domainV2/DomainsContext';
import useScrollDomains from '@app/domainV2/useScrollDomains';
// Simple mock implementations
const mockUseInView = vi.fn();
const mockUseScrollAcrossEntitiesQuery = vi.fn();
const mockUseManageDomains = vi.fn();
// Mock modules with simple return values
vi.mock('react-intersection-observer', () => ({
useInView: () => mockUseInView(),
}));
vi.mock('@graphql/search.generated', () => ({
useScrollAcrossEntitiesQuery: () => mockUseScrollAcrossEntitiesQuery(),
}));
vi.mock('@app/domainV2/useManageDomains', () => ({
default: () => mockUseManageDomains(),
}));
const createWrapper = ({ children }: { children: React.ReactNode }) => (
<MockedProvider mocks={[]} addTypename={false}>
<DomainsContext.Provider
value={{
entityData: null,
setEntityData: vi.fn(),
newDomain: null,
setNewDomain: vi.fn(),
deletedDomain: null,
setDeletedDomain: vi.fn(),
updatedDomain: null,
setUpdatedDomain: vi.fn(),
}}
>
{children}
</DomainsContext.Provider>
</MockedProvider>
);
describe('Domains Integration Tests - Simple', () => {
beforeEach(() => {
vi.clearAllMocks();
// Set up default mock returns
mockUseInView.mockReturnValue([vi.fn(), false]);
mockUseScrollAcrossEntitiesQuery.mockReturnValue({
data: null,
loading: false,
error: null,
refetch: vi.fn(),
});
mockUseManageDomains.mockReturnValue(undefined);
});
describe('Basic functionality', () => {
it('should initialize with empty data', () => {
const { result } = renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(result.current.domains).toEqual([]);
expect(result.current.hasInitialized).toBe(false);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should return scrollRef function', () => {
const { result } = renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(result.current.scrollRef).toBeDefined();
expect(typeof result.current.scrollRef).toBe('function');
});
it('should handle loading state', () => {
mockUseScrollAcrossEntitiesQuery.mockReturnValue({
data: null,
loading: true,
error: null,
refetch: vi.fn(),
});
const { result } = renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(result.current.loading).toBe(true);
});
it('should handle error state', () => {
const mockError = new Error('Test error');
mockUseScrollAcrossEntitiesQuery.mockReturnValue({
data: null,
loading: false,
error: mockError,
refetch: vi.fn(),
});
const { result } = renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(result.current.error).toBe(mockError);
});
it('should call useManageDomains hook', () => {
renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(mockUseManageDomains).toHaveBeenCalled();
});
it('should call useScrollAcrossEntitiesQuery', () => {
renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(mockUseScrollAcrossEntitiesQuery).toHaveBeenCalled();
});
it('should call useInView', () => {
renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(mockUseInView).toHaveBeenCalled();
});
});
describe('Hook integration', () => {
it('should work with different parent domain values', () => {
const { result: result1 } = renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
const { result: result2 } = renderHook(() => useScrollDomains({ parentDomain: 'urn:li:domain:test' }), {
wrapper: createWrapper,
});
// Both should initialize correctly
expect(result1.current.domains).toEqual([]);
expect(result2.current.domains).toEqual([]);
});
it('should work with skip parameter', () => {
mockUseScrollAcrossEntitiesQuery.mockReturnValue({
data: null,
loading: false,
error: null,
refetch: vi.fn(),
});
const { result } = renderHook(() => useScrollDomains({ skip: true }), {
wrapper: createWrapper,
});
expect(result.current.domains).toEqual([]);
expect(mockUseScrollAcrossEntitiesQuery).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,401 @@
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { DomainsContext, DomainsContextType, UpdatedDomain } from '@app/domainV2/DomainsContext';
import useManageDomains from '@app/domainV2/useManageDomains';
import { ListDomainFragment } from '@graphql/domain.generated';
import { EntityType } from '@types';
// Mock domain data for testing
const createMockDomain = (urn: string, name: string, parentDomain?: string): UpdatedDomain => ({
urn,
id: urn,
type: EntityType.Domain,
properties: {
name,
description: `Description for ${name}`,
},
ownership: null,
entities: null,
children: { total: 0 },
dataProducts: null,
parentDomains: null,
displayProperties: null,
parentDomain,
});
// Helper to create context wrapper for testing
const createContextWrapper = (contextValue: Partial<DomainsContextType>) => {
const defaultContextValue: DomainsContextType = {
entityData: null,
setEntityData: vi.fn(),
newDomain: null,
setNewDomain: vi.fn(),
deletedDomain: null,
setDeletedDomain: vi.fn(),
updatedDomain: null,
setUpdatedDomain: vi.fn(),
...contextValue,
};
return ({ children }: { children: React.ReactNode }) => (
<DomainsContext.Provider value={defaultContextValue}>{children}</DomainsContext.Provider>
);
};
describe('useManageDomains', () => {
let mockSetData: ReturnType<typeof vi.fn>;
let mockSetDataUrnsSet: ReturnType<typeof vi.fn>;
let mockSetNewDomain: ReturnType<typeof vi.fn>;
let mockSetDeletedDomain: ReturnType<typeof vi.fn>;
let mockSetUpdatedDomain: ReturnType<typeof vi.fn>;
const domain1 = createMockDomain('urn:li:domain:1', 'Domain 1');
const domain2 = createMockDomain('urn:li:domain:2', 'Domain 2');
beforeEach(() => {
mockSetData = vi.fn();
mockSetDataUrnsSet = vi.fn();
mockSetNewDomain = vi.fn();
mockSetDeletedDomain = vi.fn();
mockSetUpdatedDomain = vi.fn();
});
describe('Adding new domain', () => {
it('should add new domain to the list when parent domain matches', () => {
const newDomain = createMockDomain('urn:li:domain:new', 'New Domain', 'urn:li:domain:1');
const dataUrnsSet = new Set(['urn:li:domain:1', 'urn:li:domain:2']);
const wrapper = createContextWrapper({
newDomain,
setNewDomain: mockSetNewDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: 'urn:li:domain:1',
}),
{ wrapper },
);
expect(mockSetData).toHaveBeenCalledWith(expect.any(Function));
expect(mockSetDataUrnsSet).toHaveBeenCalledWith(expect.any(Function));
expect(mockSetNewDomain).toHaveBeenCalledWith(null);
// Test the data setter function
const dataSetterFn = mockSetData.mock.calls[0][0];
const newData = dataSetterFn([domain1, domain2]);
expect(newData).toEqual([newDomain, domain1, domain2]);
// Test the dataUrnsSet setter function
const urnsSetterFn = mockSetDataUrnsSet.mock.calls[0][0];
const newUrnsSet = urnsSetterFn(dataUrnsSet);
expect(newUrnsSet).toEqual(new Set(['urn:li:domain:1', 'urn:li:domain:2', 'urn:li:domain:new']));
});
it('should not add new domain when parent domain does not match', () => {
const newDomain = createMockDomain('urn:li:domain:new', 'New Domain', 'urn:li:domain:other');
const dataUrnsSet = new Set(['urn:li:domain:1', 'urn:li:domain:2']);
const wrapper = createContextWrapper({
newDomain,
setNewDomain: mockSetNewDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: 'urn:li:domain:1',
}),
{ wrapper },
);
expect(mockSetData).not.toHaveBeenCalled();
expect(mockSetDataUrnsSet).not.toHaveBeenCalled();
expect(mockSetNewDomain).not.toHaveBeenCalled();
});
it('should increase parent domain children count when new domain is added to a parent in the list', () => {
const parentDomain = createMockDomain('urn:li:domain:parent', 'Parent Domain');
parentDomain.children = { total: 5 };
const newDomain = createMockDomain('urn:li:domain:new', 'New Domain', 'urn:li:domain:parent');
const dataUrnsSet = new Set(['urn:li:domain:parent', 'urn:li:domain:2']);
const wrapper = createContextWrapper({
newDomain,
setNewDomain: mockSetNewDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: undefined, // We're at root level
}),
{ wrapper },
);
// Should call setData twice - once for parent count update
expect(mockSetData).toHaveBeenCalledTimes(1);
const dataSetterFn = mockSetData.mock.calls[0][0];
const currentData = [parentDomain, domain2];
const updatedData = dataSetterFn(currentData);
const updatedParent = updatedData.find((d: ListDomainFragment) => d.urn === 'urn:li:domain:parent');
expect(updatedParent?.children?.total).toBe(6);
});
it('should handle adding new root domain (no parent)', () => {
const newRootDomain = createMockDomain('urn:li:domain:newroot', 'New Root Domain');
// No parentDomain property for root domains
delete newRootDomain.parentDomain;
const dataUrnsSet = new Set(['urn:li:domain:1', 'urn:li:domain:2']);
const wrapper = createContextWrapper({
newDomain: newRootDomain,
setNewDomain: mockSetNewDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: undefined, // Root level
}),
{ wrapper },
);
expect(mockSetData).toHaveBeenCalledWith(expect.any(Function));
expect(mockSetDataUrnsSet).toHaveBeenCalledWith(expect.any(Function));
expect(mockSetNewDomain).toHaveBeenCalledWith(null);
});
});
describe('Deleting domain', () => {
it('should remove domain from the list when domain exists in dataUrnsSet', () => {
const deletedDomain = createMockDomain('urn:li:domain:2', 'Domain 2');
const dataUrnsSet = new Set(['urn:li:domain:1', 'urn:li:domain:2']);
const wrapper = createContextWrapper({
deletedDomain,
setDeletedDomain: mockSetDeletedDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: undefined,
}),
{ wrapper },
);
expect(mockSetData).toHaveBeenCalledWith(expect.any(Function));
expect(mockSetDeletedDomain).toHaveBeenCalledWith(null);
// Test the data filter function
const dataSetterFn = mockSetData.mock.calls[0][0];
const filteredData = dataSetterFn([domain1, domain2]);
expect(filteredData).toEqual([domain1]);
});
it('should not remove domain when domain does not exist in dataUrnsSet', () => {
const deletedDomain = createMockDomain('urn:li:domain:other', 'Other Domain');
const dataUrnsSet = new Set(['urn:li:domain:1', 'urn:li:domain:2']);
const wrapper = createContextWrapper({
deletedDomain,
setDeletedDomain: mockSetDeletedDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: undefined,
}),
{ wrapper },
);
expect(mockSetData).not.toHaveBeenCalled();
expect(mockSetDeletedDomain).not.toHaveBeenCalled();
});
it('should decrease parent domain children count when domain is deleted', () => {
const parentDomain = createMockDomain('urn:li:domain:parent', 'Parent Domain');
parentDomain.children = { total: 5 };
const deletedDomain = createMockDomain('urn:li:domain:child', 'Child Domain', 'urn:li:domain:parent');
const dataUrnsSet = new Set(['urn:li:domain:parent', 'urn:li:domain:child']);
const wrapper = createContextWrapper({
deletedDomain,
setDeletedDomain: mockSetDeletedDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: undefined,
}),
{ wrapper },
);
expect(mockSetData).toHaveBeenCalledTimes(2); // Once for deletion, once for parent count update
// Test parent count update
const parentUpdateFn = mockSetData.mock.calls[1][0];
const currentData = [parentDomain, deletedDomain];
const updatedData = parentUpdateFn(currentData);
const updatedParent = updatedData.find((d: ListDomainFragment) => d.urn === 'urn:li:domain:parent');
expect(updatedParent?.children?.total).toBe(4);
});
it('should not decrease parent count below 0', () => {
const parentDomain = createMockDomain('urn:li:domain:parent', 'Parent Domain');
parentDomain.children = { total: 0 };
const deletedDomain = createMockDomain('urn:li:domain:child', 'Child Domain', 'urn:li:domain:parent');
const dataUrnsSet = new Set(['urn:li:domain:parent', 'urn:li:domain:child']);
const wrapper = createContextWrapper({
deletedDomain,
setDeletedDomain: mockSetDeletedDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: undefined,
}),
{ wrapper },
);
const parentUpdateFn = mockSetData.mock.calls[1][0];
const currentData = [parentDomain];
const updatedData = parentUpdateFn(currentData);
const updatedParent = updatedData.find((d: ListDomainFragment) => d.urn === 'urn:li:domain:parent');
expect(updatedParent?.children?.total).toBe(0);
});
});
describe('Updating domain', () => {
it('should update domain in the list when domain exists in dataUrnsSet', () => {
const updatedDomain = createMockDomain('urn:li:domain:2', 'Updated Domain 2');
updatedDomain.properties!.description = 'Updated description';
const dataUrnsSet = new Set(['urn:li:domain:1', 'urn:li:domain:2']);
const wrapper = createContextWrapper({
updatedDomain,
setUpdatedDomain: mockSetUpdatedDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: undefined,
}),
{ wrapper },
);
expect(mockSetData).toHaveBeenCalledWith(expect.any(Function));
expect(mockSetUpdatedDomain).toHaveBeenCalledWith(null);
// Test the data update function
const dataSetterFn = mockSetData.mock.calls[0][0];
const updatedData = dataSetterFn([domain1, domain2]);
expect(updatedData[1]).toEqual(
expect.objectContaining({
urn: 'urn:li:domain:2',
properties: expect.objectContaining({
name: 'Updated Domain 2',
description: 'Updated description',
}),
}),
);
});
it('should not update domain when domain does not exist in dataUrnsSet', () => {
const updatedDomain = createMockDomain('urn:li:domain:other', 'Other Domain');
const dataUrnsSet = new Set(['urn:li:domain:1', 'urn:li:domain:2']);
const wrapper = createContextWrapper({
updatedDomain,
setUpdatedDomain: mockSetUpdatedDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: undefined,
}),
{ wrapper },
);
expect(mockSetData).not.toHaveBeenCalled();
expect(mockSetUpdatedDomain).not.toHaveBeenCalled();
});
});
describe('Effect dependencies', () => {
it('should react to changes in context values', () => {
const newDomain = createMockDomain('urn:li:domain:new', 'New Domain');
const dataUrnsSet = new Set(['urn:li:domain:1']);
const wrapper = createContextWrapper({
newDomain,
setNewDomain: mockSetNewDomain,
deletedDomain: null,
setDeletedDomain: mockSetDeletedDomain,
updatedDomain: null,
setUpdatedDomain: mockSetUpdatedDomain,
});
renderHook(
() =>
useManageDomains({
dataUrnsSet,
setDataUrnsSet: mockSetDataUrnsSet,
setData: mockSetData,
parentDomain: undefined,
}),
{ wrapper },
);
// Should be called because newDomain is provided and parentDomain matches (both undefined)
expect(mockSetData).toHaveBeenCalled();
expect(mockSetDataUrnsSet).toHaveBeenCalled();
expect(mockSetNewDomain).toHaveBeenCalledWith(null);
});
});
});

View File

@ -0,0 +1,118 @@
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { DomainsContext } from '@app/domainV2/DomainsContext';
import useScrollDomains from '@app/domainV2/useScrollDomains';
// Simple mock implementations
const mockUseInView = vi.fn();
const mockUseScrollAcrossEntitiesQuery = vi.fn();
const mockUseManageDomains = vi.fn();
// Mock modules with simple return values
vi.mock('react-intersection-observer', () => ({
useInView: () => mockUseInView(),
}));
vi.mock('@graphql/search.generated', () => ({
useScrollAcrossEntitiesQuery: () => mockUseScrollAcrossEntitiesQuery(),
}));
vi.mock('@app/domainV2/useManageDomains', () => ({
default: () => mockUseManageDomains(),
}));
const createWrapper = ({ children }: { children: React.ReactNode }) => (
<MockedProvider mocks={[]} addTypename={false}>
<DomainsContext.Provider
value={{
entityData: null,
setEntityData: vi.fn(),
newDomain: null,
setNewDomain: vi.fn(),
deletedDomain: null,
setDeletedDomain: vi.fn(),
updatedDomain: null,
setUpdatedDomain: vi.fn(),
}}
>
{children}
</DomainsContext.Provider>
</MockedProvider>
);
describe('useScrollDomains - Simplified Tests', () => {
beforeEach(() => {
vi.clearAllMocks();
// Set up default mock returns
mockUseInView.mockReturnValue([vi.fn(), false]);
mockUseScrollAcrossEntitiesQuery.mockReturnValue({
data: null,
loading: false,
error: null,
refetch: vi.fn(),
});
mockUseManageDomains.mockReturnValue(undefined);
});
it('should initialize with empty data', () => {
const { result } = renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(result.current.domains).toEqual([]);
expect(result.current.hasInitialized).toBe(false);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should return scrollRef function', () => {
const { result } = renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(result.current.scrollRef).toBeDefined();
expect(typeof result.current.scrollRef).toBe('function');
});
it('should handle loading state', () => {
mockUseScrollAcrossEntitiesQuery.mockReturnValue({
data: null,
loading: true,
error: null,
refetch: vi.fn(),
});
const { result } = renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(result.current.loading).toBe(true);
});
it('should handle error state', () => {
const mockError = new Error('Test error');
mockUseScrollAcrossEntitiesQuery.mockReturnValue({
data: null,
loading: false,
error: mockError,
refetch: vi.fn(),
});
const { result } = renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(result.current.error).toBe(mockError);
});
it('should call useManageDomains hook', () => {
renderHook(() => useScrollDomains({}), {
wrapper: createWrapper,
});
expect(mockUseManageDomains).toHaveBeenCalled();
});
});

View File

@ -1,10 +1,8 @@
import { useApolloClient } from '@apollo/client';
import { Button, Tooltip } from '@components';
import React, { useState } from 'react';
import styled from 'styled-components';
import CreateDomainModal from '@app/domainV2/CreateDomainModal';
import { updateListDomainsCache } from '@app/domainV2/utils';
const Wrapper = styled.div`
font-size: 20px;
@ -31,7 +29,6 @@ const StyledButton = styled(Button)`
export default function DomainsSidebarHeader() {
const [isCreatingDomain, setIsCreatingDomain] = useState(false);
const client = useApolloClient();
return (
<Wrapper>
@ -45,14 +42,7 @@ export default function DomainsSidebarHeader() {
onClick={() => setIsCreatingDomain(true)}
/>
</Tooltip>
{isCreatingDomain && (
<CreateDomainModal
onClose={() => setIsCreatingDomain(false)}
onCreate={(urn, id, name, description, parentDomain) => {
updateListDomainsCache(client, urn, id, name, description, parentDomain);
}}
/>
)}
{isCreatingDomain && <CreateDomainModal onClose={() => setIsCreatingDomain(false)} />}
</Wrapper>
);
}

View File

@ -1,4 +1,3 @@
import { useApolloClient } from '@apollo/client';
import React, { useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import styled from 'styled-components/macro';
@ -6,7 +5,6 @@ import styled from 'styled-components/macro';
import CreateDomainModal from '@app/domainV2/CreateDomainModal';
import { useDomainsContext as useDomainsContextV2 } from '@app/domainV2/DomainsContext';
import RootDomains from '@app/domainV2/nestedDomains/RootDomains';
import { updateListDomainsCache } from '@app/domainV2/utils';
import { OnboardingTour } from '@app/onboarding/OnboardingTour';
import { DOMAINS_CREATE_DOMAIN_ID, DOMAINS_INTRO_ID } from '@app/onboarding/config/DomainsOnboardingConfig';
import { Button } from '@src/alchemy-components';
@ -35,7 +33,6 @@ const Header = styled.div`
export default function ManageDomainsPageV2() {
const { setEntityData } = useDomainsContextV2();
const [isCreatingDomain, setIsCreatingDomain] = useState(false);
const client = useApolloClient();
const isShowNavBarRedesign = useShowNavBarRedesign();
const location = useLocation();
const history = useHistory();
@ -69,14 +66,7 @@ export default function ManageDomainsPageV2() {
</Button>
</Header>
<RootDomains setIsCreatingDomain={setIsCreatingDomain} />
{isCreatingDomain && (
<CreateDomainModal
onClose={() => setIsCreatingDomain(false)}
onCreate={(urn, id, name, description, parentDomain) =>
updateListDomainsCache(client, urn, id, name, description, parentDomain)
}
/>
)}
{isCreatingDomain && <CreateDomainModal onClose={() => setIsCreatingDomain(false)} />}
</PageWrapper>
);
}

View File

@ -97,7 +97,7 @@ export default function ManageDomainsSidebarV2({ isEntityProfile }: Props) {
<StyledSidebar>
<DomainSearch isCollapsed={isClosed} unhideSidebar={unhideSidebar} />
<ThinDivider />
<DomainNavigator isCollapsed={isClosed} unhideSidebar={unhideSidebar} />
<DomainNavigator isCollapsed={isClosed} unhideSidebar={unhideSidebar} variant="sidebar" />
</StyledSidebar>
</StyledEntitySidebarContainer>
);

View File

@ -4,7 +4,8 @@ import styled from 'styled-components';
import EmptyDomainDescription from '@app/domainV2/EmptyDomainDescription';
import EmptyDomainsSection from '@app/domainV2/EmptyDomainsSection';
import useListDomains from '@app/domainV2/useListDomains';
import useScrollDomains from '@app/domainV2/useScrollDomains';
import Loading from '@app/shared/Loading';
import { Message } from '@app/shared/Message';
import { useEntityRegistry } from '@app/useEntityRegistry';
@ -28,18 +29,21 @@ const ResultWrapper = styled.div`
border: 1px solid #ebecf0;
`;
const LoadingWrapper = styled.div`
padding: 16px;
`;
interface Props {
setIsCreatingDomain: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function RootDomains({ setIsCreatingDomain }: Props) {
const entityRegistry = useEntityRegistry();
const { loading, error, data, sortedDomains } = useListDomains({});
const { domains, hasInitialized, loading, error, scrollRef } = useScrollDomains({});
return (
<>
{!data && loading && <Message type="loading" content="Loading domains..." />}
{error && <Message type="error" content="Failed to load domains. An unexpected error occurred." />}
{!loading && (!data || !data?.listDomains?.domains?.length) && (
{hasInitialized && domains.length === 0 && (
<EmptyDomainsSection
icon={<ReadOutlined />}
title="Organize your data"
@ -48,11 +52,17 @@ export default function RootDomains({ setIsCreatingDomain }: Props) {
/>
)}
<DomainsWrapper>
{sortedDomains?.map((domain) => (
{domains?.map((domain) => (
<ResultWrapper key={domain.urn}>
{entityRegistry.renderSearchResult(EntityType.Domain, { entity: domain, matchedFields: [] })}
</ResultWrapper>
))}
{loading && (
<LoadingWrapper>
<Loading height={24} marginTop={0} />
</LoadingWrapper>
)}
{domains.length > 0 && <div ref={scrollRef} />}
</DomainsWrapper>
</>
);

View File

@ -3,8 +3,10 @@ import React from 'react';
import styled from 'styled-components';
import DomainNode from '@app/domainV2/nestedDomains/domainNavigator/DomainNode';
import useListDomains from '@app/domainV2/useListDomains';
import { DomainNavigatorVariant } from '@app/domainV2/nestedDomains/types';
import useScrollDomains from '@app/domainV2/useScrollDomains';
import { ANTD_GRAY } from '@app/entity/shared/constants';
import Loading from '@app/shared/Loading';
import { Domain } from '@types';
@ -14,39 +16,55 @@ const NavigatorWrapper = styled.div`
overflow: auto;
`;
const LoadingWrapper = styled.div`
padding: 16px;
`;
interface Props {
domainUrnToHide?: string;
selectDomainOverride?: (domain: Domain) => void;
isCollapsed: boolean;
isCollapsed?: boolean;
unhideSidebar?: () => void;
variant?: DomainNavigatorVariant;
}
export default function DomainNavigator({ domainUrnToHide, isCollapsed, selectDomainOverride, unhideSidebar }: Props) {
const { sortedDomains, error, loading } = useListDomains({});
const noDomainsFound: boolean = !sortedDomains || sortedDomains.length === 0;
export default function DomainNavigator({
domainUrnToHide,
isCollapsed,
selectDomainOverride,
unhideSidebar,
variant = 'select',
}: Props) {
const { domains, hasInitialized, loading, error, scrollRef } = useScrollDomains({});
return (
<NavigatorWrapper>
{error && <Alert message="Loading Domains failed." showIcon type="error" />}
{!loading && noDomainsFound && (
{hasInitialized && domains.length === 0 && (
<Empty
description="No Domains Found"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ color: ANTD_GRAY[7] }}
/>
)}
{!noDomainsFound &&
sortedDomains?.map((domain) => (
<DomainNode
key={domain.urn}
domain={domain as Domain}
numDomainChildren={domain.children?.total || 0}
domainUrnToHide={domainUrnToHide}
selectDomainOverride={selectDomainOverride}
isCollapsed={isCollapsed}
unhideSidebar={unhideSidebar}
/>
))}
{domains?.map((domain) => (
<DomainNode
key={domain.urn}
domain={domain as Domain}
numDomainChildren={domain.children?.total || 0}
domainUrnToHide={domainUrnToHide}
selectDomainOverride={selectDomainOverride}
isCollapsed={isCollapsed}
unhideSidebar={unhideSidebar}
variant={variant}
/>
))}
{loading && (
<LoadingWrapper>
<Loading height={24} marginTop={0} />
</LoadingWrapper>
)}
{domains.length > 0 && <div ref={scrollRef} />}
</NavigatorWrapper>
);
}

View File

@ -1,13 +1,15 @@
import { Tooltip, colors } from '@components';
import { Pill, Tooltip } from '@components';
import { Typography } from 'antd';
import React, { useEffect, useMemo } from 'react';
import { useHistory } from 'react-router';
import styled from 'styled-components';
import { useDomainsContext as useDomainsContextV2 } from '@app/domainV2/DomainsContext';
import useListDomains from '@app/domainV2/useListDomains';
import { DomainNavigatorVariant } from '@app/domainV2/nestedDomains/types';
import useScrollDomains from '@app/domainV2/useScrollDomains';
import { REDESIGN_COLORS } from '@app/entityV2/shared/constants';
import { DomainColoredIcon } from '@app/entityV2/shared/links/DomainColoredIcon';
import Loading from '@app/shared/Loading';
import { BodyContainer, BodyGridExpander } from '@app/shared/components';
import useToggle from '@app/shared/useToggle';
import { RotatingTriangle } from '@app/sharedV2/sidebar/components';
@ -15,26 +17,11 @@ import { useEntityRegistry } from '@app/useEntityRegistry';
import { Domain } from '@types';
const Count = styled.div`
color: ${colors.gray[1700]};
font-size: 12px;
padding: 0 8px;
margin-left: 8px;
border-radius: 20px;
background-color: ${colors.gray[100]};
height: 22px;
min-width: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`;
const NameWrapper = styled(Typography.Text)<{ $isSelected: boolean; $addLeftPadding: boolean }>`
flex: 1;
padding: 2px;
${(props) => props.$isSelected && `color: ${props.theme.styles['primary-color']};`}
${(props) => props.$addLeftPadding && 'padding-left: 20px;'}
${(props) => props.$addLeftPadding && 'padding-left: 22px;'}
&:hover {
cursor: pointer;
@ -69,12 +56,13 @@ const ButtonWrapper = styled.span<{ $addLeftPadding: boolean; $isSelected: boole
}
`;
const RowWrapper = styled.div<{ $isSelected: boolean; isOpen?: boolean }>`
const RowWrapper = styled.div<{ $isSelected: boolean; isOpen?: boolean; $variant: DomainNavigatorVariant }>`
align-items: center;
display: flex;
width: 100%;
border-bottom: 1px solid ${REDESIGN_COLORS.COLD_GREY_TEXT_BLUE_1};
padding: 12px;
border-bottom: ${({ $variant }) =>
$variant === 'select' ? 'none' : `1px solid ${REDESIGN_COLORS.COLD_GREY_TEXT_BLUE_1}`};
padding: ${({ $variant }) => ($variant === 'select' ? '6px' : '12px')};
${(props) => props.isOpen && `background-color: ${REDESIGN_COLORS.SECTION_BACKGROUND};`}
${(props) => props.$isSelected && `background-color: ${REDESIGN_COLORS.LIGHT_TEXT_DARK_BACKGROUND};`}
&:hover {
@ -109,6 +97,10 @@ const Text = styled.div`
width: 80%;
`;
const LoadingWrapper = styled.div`
padding: 16px;
`;
interface Props {
domain: Domain;
numDomainChildren: number;
@ -117,6 +109,7 @@ interface Props {
selectDomainOverride?: (domain: Domain) => void;
unhideSidebar?: () => void;
$paddingLeft?: number;
variant?: DomainNavigatorVariant;
}
export default function DomainNode({
@ -127,6 +120,7 @@ export default function DomainNode({
selectDomainOverride,
unhideSidebar,
$paddingLeft = 0,
variant = 'select',
}: Props) {
const shouldHideDomain = domainUrnToHide === domain.urn;
const history = useHistory();
@ -136,7 +130,10 @@ export default function DomainNode({
initialValue: false,
closeDelay: 250,
});
const { sortedDomains } = useListDomains({ parentDomain: domain.urn, skip: !isOpen || shouldHideDomain });
const { domains, loading, scrollRef } = useScrollDomains({
parentDomain: domain.urn,
skip: !isOpen || shouldHideDomain,
});
const isOnEntityPage = entityData && entityData.urn === domain.urn;
const displayName = entityRegistry.getDisplayName(domain.type, isOnEntityPage ? entityData : domain);
const isInSelectMode = !!selectDomainOverride;
@ -169,8 +166,7 @@ export default function DomainNode({
if (shouldHideDomain) return null;
const finalNumChildren = sortedDomains?.length ?? numDomainChildren;
const hasDomainChildren = !!finalNumChildren;
const hasDomainChildren = !!numDomainChildren;
return (
<>
@ -178,6 +174,7 @@ export default function DomainNode({
data-testid="domain-list-item"
$isSelected={isDomainNodeSelected && !isCollapsed}
isOpen={isOpen && !isClosing}
$variant={variant}
>
{!isCollapsed && hasDomainChildren && (
<ButtonWrapper
@ -210,23 +207,34 @@ export default function DomainNode({
{!isCollapsed && displayName}
</DisplayName>
</Text>
{!isCollapsed && hasDomainChildren && <Count>{finalNumChildren}</Count>}
{!isCollapsed && hasDomainChildren && <Pill label={`${numDomainChildren}`} size="sm" />}
</NameWrapper>
</Tooltip>
</RowWrapper>
<StyledExpander isOpen={isOpen && !isClosing} paddingLeft={paddingLeft}>
<BodyContainer style={{ width: '100%' }}>
{sortedDomains?.map((childDomain) => (
<DomainNode
key={domain.urn}
domain={childDomain as Domain}
numDomainChildren={childDomain.children?.total || 0}
domainUrnToHide={domainUrnToHide}
selectDomainOverride={selectDomainOverride}
unhideSidebar={unhideSidebar}
$paddingLeft={paddingLeft}
/>
))}
{isOpen && (
<>
{domains?.map((childDomain) => (
<DomainNode
key={domain.urn}
domain={childDomain as Domain}
numDomainChildren={childDomain.children?.total || 0}
domainUrnToHide={domainUrnToHide}
selectDomainOverride={selectDomainOverride}
unhideSidebar={unhideSidebar}
$paddingLeft={paddingLeft}
variant={variant}
/>
))}
{loading && (
<LoadingWrapper>
<Loading height={16} marginTop={0} />
</LoadingWrapper>
)}
{domains.length > 0 && <div ref={scrollRef} />}
</>
)}
</BodyContainer>
</StyledExpander>
</>

View File

@ -0,0 +1 @@
export type DomainNavigatorVariant = 'sidebar' | 'select';

View File

@ -1,28 +0,0 @@
import { useSortedDomains } from '@app/domainV2/utils';
import { useListDomainsQuery } from '@graphql/domain.generated';
interface Props {
parentDomain?: string;
skip?: boolean;
sortBy?: 'displayName';
}
export default function useListDomains({ parentDomain, skip, sortBy = 'displayName' }: Props) {
const { data, error, loading, refetch } = useListDomainsQuery({
skip,
variables: {
input: {
start: 0,
count: 1000, // don't paginate the home page, get all root level domains
parentDomain,
},
},
fetchPolicy: 'network-only', // always use network request first to populate cache
nextFetchPolicy: 'cache-first', // then use cache after that so we can manipulate it
});
const sortedDomains = useSortedDomains(data?.listDomains?.domains, sortBy);
return { data, sortedDomains, error, loading, refetch };
}

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { useDomainsContext } from '@app/domainV2/DomainsContext';
import { ListDomainFragment } from '@graphql/domain.generated';
interface Props {
dataUrnsSet: Set<string>;
setDataUrnsSet: React.Dispatch<React.SetStateAction<Set<string>>>;
setData: React.Dispatch<React.SetStateAction<ListDomainFragment[]>>;
parentDomain?: string;
}
export default function useManageDomains({ dataUrnsSet, setDataUrnsSet, setData, parentDomain }: Props) {
const { newDomain, setNewDomain, deletedDomain, setDeletedDomain, updatedDomain, setUpdatedDomain } =
useDomainsContext();
// Adding new domain
useEffect(() => {
if (newDomain && newDomain.parentDomain === parentDomain) {
setData((prevData) => [newDomain, ...prevData]);
setDataUrnsSet((currSet) => new Set([...currSet, newDomain.urn]));
setNewDomain(null);
}
// adding a new domain should increase the count of its parent
const newDomainParentUrn = newDomain?.parentDomain;
if (newDomainParentUrn && dataUrnsSet.has(newDomainParentUrn)) {
setData((currData) =>
currData.map((d) => {
if (d.urn === newDomainParentUrn) {
return { ...d, children: { total: (d.children?.total || 0) + 1 } };
}
return d;
}),
);
}
}, [newDomain, dataUrnsSet, parentDomain, setData, setDataUrnsSet, setNewDomain]);
// Deleting domain
useEffect(() => {
if (deletedDomain && dataUrnsSet.has(deletedDomain.urn)) {
setData((prevData) => prevData.filter((d) => d.urn !== deletedDomain.urn));
setDeletedDomain(null);
}
// deleting a new domain should decrease the count of its parent
const deletedDomainParentUrn = deletedDomain?.parentDomain;
if (deletedDomainParentUrn && dataUrnsSet.has(deletedDomainParentUrn)) {
setData((currData) =>
currData.map((d) => {
if (d.urn === deletedDomainParentUrn) {
return { ...d, children: { total: Math.max((d.children?.total || 0) - 1, 0) } };
}
return d;
}),
);
}
}, [deletedDomain, dataUrnsSet, setData, setDeletedDomain]);
// Updating domain
useEffect(() => {
if (updatedDomain && dataUrnsSet.has(updatedDomain.urn)) {
setData((prevData) =>
prevData.map((d) => {
if (d.urn === updatedDomain.urn) {
return { ...d, ...updatedDomain };
}
return d;
}),
);
setUpdatedDomain(null);
}
}, [updatedDomain, dataUrnsSet, setData, setUpdatedDomain]);
}

View File

@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import useManageDomains from '@app/domainV2/useManageDomains';
import { ENTITY_NAME_FIELD } from '@app/searchV2/context/constants';
import { ListDomainFragment } from '@graphql/domain.generated';
import { useScrollAcrossEntitiesQuery } from '@graphql/search.generated';
import { EntityType, FilterOperator, SortOrder } from '@types';
export const DOMAIN_COUNT = 25;
export function getDomainsScrollInput(parentDomain: string | null, scrollId: string | null) {
return {
input: {
scrollId,
query: '*',
types: [EntityType.Domain],
orFilters: parentDomain
? [{ and: [{ field: 'parentDomain', values: [parentDomain] }] }]
: [{ and: [{ field: 'parentDomain', condition: FilterOperator.Exists, negated: true }] }],
count: DOMAIN_COUNT,
sortInput: {
sortCriteria: [{ field: ENTITY_NAME_FIELD, sortOrder: SortOrder.Ascending }],
},
searchFlags: { skipCache: true },
},
};
}
interface Props {
parentDomain?: string;
skip?: boolean;
}
export default function useScrollDomains({ parentDomain, skip }: Props) {
const [hasInitialized, setHasInitialized] = useState(false);
const [data, setData] = useState<ListDomainFragment[]>([]);
const [dataUrnsSet, setDataUrnsSet] = useState<Set<string>>(new Set());
const [scrollId, setScrollId] = useState<string | null>(null);
const {
data: scrollData,
loading,
error,
refetch,
} = useScrollAcrossEntitiesQuery({
variables: {
...getDomainsScrollInput(parentDomain || null, scrollId),
},
skip,
notifyOnNetworkStatusChange: true,
fetchPolicy: 'cache-and-network', // to do check this still good
});
// Manage CRUD of domains on domains page
useManageDomains({ dataUrnsSet, setDataUrnsSet, setData, parentDomain });
// Handle initial data and updates from scroll
useEffect(() => {
if (scrollData?.scrollAcrossEntities?.searchResults) {
const newResults = (scrollData.scrollAcrossEntities.searchResults
.filter((r) => !dataUrnsSet.has(r.entity.urn))
.map((r) => r.entity)
.filter((e) => e.type === EntityType.Domain) || []) as ListDomainFragment[];
if (newResults.length > 0) {
setData((currData) => [...currData, ...newResults]);
setDataUrnsSet((currSet) => {
const newSet = new Set(currSet);
newResults.forEach((r) => newSet.add(r.urn));
return newSet;
});
}
setHasInitialized(true);
}
}, [scrollData, dataUrnsSet]);
const nextScrollId = scrollData?.scrollAcrossEntities?.nextScrollId;
const [scrollRef, inView] = useInView({ triggerOnce: false });
useEffect(() => {
if (!loading && nextScrollId && scrollId !== nextScrollId && inView) {
setScrollId(nextScrollId);
}
}, [inView, nextScrollId, scrollId, loading]);
return {
domains: data,
hasInitialized,
loading,
error,
refetch,
scrollRef,
};
}

View File

@ -2,8 +2,8 @@ import { CloseCircleFilled } from '@ant-design/icons';
import { Empty, Select } from 'antd';
import React, { MouseEvent } from 'react';
import DomainNavigator from '@app/domain/nestedDomains/domainNavigator/DomainNavigator';
import domainAutocompleteOptions from '@app/domainV2/DomainAutocompleteOptions';
import DomainNavigator from '@app/domainV2/nestedDomains/domainNavigator/DomainNavigator';
import useParentSelector from '@app/entityV2/shared/EntityDropdown/useParentSelector';
import { ANTD_GRAY } from '@app/entityV2/shared/constants';
import ClickOutside from '@app/shared/ClickOutside';
@ -102,6 +102,7 @@ export default function DomainParentSelect({ selectedParentUrn, setSelectedParen
<DomainNavigator
domainUrnToHide={isMoving ? domainUrn : undefined}
selectDomainOverride={selectDomain}
isCollapsed={false}
/>
</BrowserWrapper>
</ClickOutside>

View File

@ -52,7 +52,7 @@ function MoveDomainModal(props: Props) {
.then(() => {
message.loading({ content: 'Updating...', duration: 2 });
const newParentToUpdate = selectedParentUrn || undefined;
handleMoveDomainComplete(domainUrn, newParentToUpdate);
handleMoveDomainComplete(newParentToUpdate);
setTimeout(() => {
message.success({
content: `Moved ${entityRegistry.getEntityName(EntityType.Domain)}!`,

View File

@ -1,27 +1,25 @@
import { useApolloClient } from '@apollo/client';
import { useDomainsContext } from '@app/domain/DomainsContext';
import { removeFromListDomainsCache } from '@app/domain/utils';
import { UpdatedDomain, useDomainsContext } from '@app/domainV2/DomainsContext';
import { GenericEntityProperties } from '@app/entity/shared/types';
import { EntityType } from '@types';
interface DeleteDomainProps {
entityData: GenericEntityProperties;
urn: string;
}
export function useHandleDeleteDomain({ entityData, urn }: DeleteDomainProps) {
const client = useApolloClient();
const { parentDomainsToUpdate, setParentDomainsToUpdate } = useDomainsContext();
const { setDeletedDomain } = useDomainsContext();
const handleDeleteDomain = () => {
if (entityData.parentDomains && entityData.parentDomains.domains.length > 0) {
const parentDomainUrn = entityData.parentDomains.domains[0].urn;
removeFromListDomainsCache(client, urn, 1, 1000, parentDomainUrn);
setParentDomainsToUpdate([...parentDomainsToUpdate, parentDomainUrn]);
} else {
removeFromListDomainsCache(client, urn, 1, 1000);
}
const parentDomainUrn = entityData?.parentDomains?.domains?.[0]?.urn || undefined;
const deletedDomain: UpdatedDomain = {
urn,
type: EntityType.Domain,
id: urn,
parentDomain: parentDomainUrn,
};
setDeletedDomain(deletedDomain);
};
return { handleDeleteDomain };

View File

@ -1,21 +1,17 @@
import { useApolloClient } from '@apollo/client';
import { EventType } from '@app/analytics';
import analytics from '@app/analytics/analytics';
import { removeFromListDomainsCache, updateListDomainsCache } from '@app/domain/utils';
import { useDomainsContext } from '@app/domainV2/DomainsContext';
import { UpdatedDomain, useDomainsContext } from '@app/domainV2/DomainsContext';
import { Domain } from '@types';
export function useHandleMoveDomainComplete() {
const client = useApolloClient();
const { entityData } = useDomainsContext();
const { entityData, setNewDomain, setDeletedDomain } = useDomainsContext();
const handleMoveDomainComplete = (urn: string, newParentUrn?: string) => {
const handleMoveDomainComplete = (newParentUrn?: string) => {
if (!entityData) return;
const domain = entityData as Domain;
const oldParentUrn = domain.parentDomains?.domains?.[0].urn;
const oldParentUrn = domain.parentDomains?.domains?.[0]?.urn;
analytics.event({
type: EventType.MoveDomainEvent,
@ -23,15 +19,17 @@ export function useHandleMoveDomainComplete() {
parentDomainUrn: newParentUrn,
});
removeFromListDomainsCache(client, urn, 1, 1000, oldParentUrn);
updateListDomainsCache(
client,
domain.urn,
undefined,
domain.properties?.name ?? '',
domain.properties?.description ?? '',
newParentUrn,
);
const deletedDomain: UpdatedDomain = {
...domain,
parentDomain: oldParentUrn,
};
setDeletedDomain(deletedDomain);
const newDomain: UpdatedDomain = {
...domain,
parentDomain: newParentUrn,
};
setNewDomain(newDomain);
};
return { handleMoveDomainComplete };

View File

@ -5,6 +5,7 @@ import styled from 'styled-components/macro';
import colors from '@components/theme/foundations/colors';
import { useDomainsContext } from '@app/domainV2/DomainsContext';
import { useEntityData, useRefetch } from '@app/entity/shared/EntityContext';
import { useGlossaryEntityData } from '@app/entityV2/shared/GlossaryEntityContext';
import { getParentNodeToUpdate, updateGlossarySidebar } from '@app/glossary/utils';
@ -54,6 +55,7 @@ function EntityName(props: Props) {
const refetch = useRefetch();
const entityRegistry = useEntityRegistry();
const { isInGlossaryContext, urnsToUpdate, setUrnsToUpdate } = useGlossaryEntityData();
const { setUpdatedDomain } = useDomainsContext();
const { urn, entityType, entityData } = useEntityData();
const entityName = entityData ? entityRegistry.getDisplayName(entityType, entityData) : '';
const [updatedName, setUpdatedName] = useState(entityName);
@ -87,6 +89,17 @@ function EntityName(props: Props) {
const parentNodeToUpdate = getParentNodeToUpdate(entityData, entityType);
updateGlossarySidebar([parentNodeToUpdate], urnsToUpdate, setUrnsToUpdate);
}
if (setUpdatedDomain !== undefined) {
const updatedDomain = {
urn,
type: EntityType.Domain,
id: urn,
properties: {
name,
},
};
setUpdatedDomain(updatedDomain);
}
})
.catch((e: unknown) => {
message.destroy();

View File

@ -1,8 +1,8 @@
import { Empty, Form, Modal, Select, message } from 'antd';
import React, { useRef, useState } from 'react';
import DomainNavigator from '@app/domain/nestedDomains/domainNavigator/DomainNavigator';
import domainAutocompleteOptions from '@app/domainV2/DomainAutocompleteOptions';
import DomainNavigator from '@app/domainV2/nestedDomains/domainNavigator/DomainNavigator';
import { useEntityContext } from '@app/entity/shared/EntityContext';
import { ANTD_GRAY } from '@app/entityV2/shared/constants';
import { handleBatchError } from '@app/entityV2/shared/utils';
@ -227,7 +227,7 @@ export const SetDomainModal = ({ urns, onCloseModal, refetch, defaultValue, onOk
options={domainAutocompleteOptions(domainResult, searchLoading, entityRegistry)}
/>
<BrowserWrapper isHidden={!isShowingDomainNavigator}>
<DomainNavigator selectDomainOverride={selectDomainFromBrowser} displayDomainColoredIcon />
<DomainNavigator selectDomainOverride={selectDomainFromBrowser} />
</BrowserWrapper>
</ClickOutside>
</Form.Item>

View File

@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components/macro';
import DomainNavigator from '@app/domain/nestedDomains/domainNavigator/DomainNavigator';
import DomainNavigator from '@app/domainV2/nestedDomains/domainNavigator/DomainNavigator';
import { RESOURCE_TYPE, RESOURCE_URN, TYPE, URN } from '@app/permissions/policy/constants';
import {
EMPTY_POLICY,

View File

@ -1,9 +1,10 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import PolicyPrivilegeForm from '@app/permissions/policy/PolicyPrivilegeForm';
import * as policyUtils from '@app/permissions/policy/policyUtils';
import { render } from '@utils/test-utils/customRender';
import { EntityType, PolicyMatchCondition, PolicyType, ResourceFilter } from '@types';

View File

@ -48,29 +48,51 @@ query getDomain($urn: String!) {
}
}
fragment ListDomain on Domain {
urn
id
type
properties {
name
description
}
parentDomains {
...parentDomainsFields
}
ownership {
...ownershipFields
}
displayProperties {
...displayPropertiesFields
}
...domainEntitiesFields
institutionalMemory {
...institutionalMemoryFields
}
}
query listDomains($input: ListDomainsInput!) {
listDomains(input: $input) {
start
count
total
domains {
urn
id
type
properties {
name
description
...ListDomain
}
}
}
query scrollAcrossDomains($input: ScrollAcrossEntitiesInput!) {
scrollAcrossEntities(input: $input) {
nextScrollId
count
total
searchResults {
entity {
... on Domain {
...ListDomain
}
}
parentDomains {
...parentDomainsFields
}
ownership {
...ownershipFields
}
displayProperties {
...displayPropertiesFields
}
...domainEntitiesFields
}
}
}

View File

@ -0,0 +1,24 @@
import { MockedProvider, MockedProviderProps } from '@apollo/client/testing';
import { RenderOptions, render } from '@testing-library/react';
import React from 'react';
// ApolloTestWrapper wraps children in MockedProvider with default settings
export const ApolloTestWrapper: React.FC<Partial<MockedProviderProps> & { children: React.ReactNode }> = ({
mocks = [],
addTypename = false,
children,
...rest
}) => (
<MockedProvider mocks={mocks} addTypename={addTypename} {...rest}>
{children}
</MockedProvider>
);
// Custom render that always wraps with ApolloTestWrapper
const customRender = (ui: React.ReactElement, options?: RenderOptions & { apolloMocks?: any[] }) =>
render(<ApolloTestWrapper mocks={options?.apolloMocks}>{ui}</ApolloTestWrapper>, options);
// Re-export everything from @testing-library/react
export * from '@testing-library/react';
// Override render export
export { customRender as render };