Skip to content

Commit

Permalink
refactor(react-components): LayersButton component (#5009)
Browse files Browse the repository at this point in the history
* interim commit

* fix the type error

* refractored Layers button to use MVVM and fixed cyclic dependency

* added test file before merging master

* refratored to use context

* removed unnecessary memozied

* lint fix

* updated test file

* removed unused file

* removed madge package

* added test for LayerButton component

* added basic test for useModelHandler and iseSyncExternalLayersState hook

* added test for updateViewerFromExternalState

* reverted search storybook example changes

* updated test files to use provider for sdk, renderTarget

* added testing-library/jest-dom to test DOM element in test files

* removed @testing-library/jest-dom and used existing happy-dom instead for test react component

* removed setUpTest.ts entry from tsconfig

* lint fix

* added most of the test in layers button

* removed unused element.ts file

* fixed type error in test

* removed unsafe type conversion in test file

* addressed review comment from risk-review-team

* added viewer and viewer.models as context
  • Loading branch information
pramod-cog authored Feb 28, 2025
1 parent 9da4014 commit 2434b36
Show file tree
Hide file tree
Showing 30 changed files with 978 additions and 136 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*!
* Copyright 2025 Cognite AS
*/
import { createContext } from 'react';
import { useModelHandlers } from './hooks/useModelHandlers';
import { useSyncExternalLayersState } from './hooks/useSyncExternalLayersState';
import { ModelLayerSelection } from './components/ModelLayerSelection';
import { useReveal } from '../../RevealCanvas';
import { use3dModels } from '../../../hooks/use3dModels';

export type LayersButtonDependencies = {
useModelHandlers: typeof useModelHandlers;
useSyncExternalLayersState: typeof useSyncExternalLayersState;
ModelLayerSelection: typeof ModelLayerSelection;
useReveal: typeof useReveal;
use3dModels: typeof use3dModels;
};

export const LayersButtonContext = createContext<LayersButtonDependencies>({
useModelHandlers,
useSyncExternalLayersState,
ModelLayerSelection,
useReveal,
use3dModels
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*!
* Copyright 2025 Cognite AS
*/
import { render } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import type { ReactElement, ReactNode } from 'react';
import { LayersButton } from './LayersButton';
import type { LayersButtonProps } from './LayersButton';
import { LayersButtonContext, type LayersButtonDependencies } from './LayersButton.context';
import {
createCadHandlerMock,
createPointCloudHandlerMock,
createImage360HandlerMock
} from '../../../../tests/tests-utilities/fixtures/modelHandler';
import { type ModelLayerHandlers } from './types';
import { cadMock } from '../../../../tests/tests-utilities/fixtures/cadModel';
import { viewerMock } from '../../../../tests/tests-utilities/fixtures/viewer';

describe(LayersButton.name, () => {
const mockCadHandler = createCadHandlerMock();
const mockPointCloudHandler = createPointCloudHandlerMock();
const mockImage360Handler = createImage360HandlerMock();
const defaultProps: LayersButtonProps = {
layersState: {
cadLayers: [{ revisionId: 456, applied: true, index: 0 }],
pointCloudLayers: [{ revisionId: 123, applied: true, index: 0 }],
image360Layers: [{ siteId: 'site-id', applied: true }]
},
setLayersState: vi.fn(),
defaultLayerConfiguration: undefined
};

const defaultDependencies: LayersButtonDependencies = {
useModelHandlers: vi.fn((): [ModelLayerHandlers, () => void] => [
{
cadHandlers: [mockCadHandler],
pointCloudHandlers: [mockPointCloudHandler],
image360Handlers: [mockImage360Handler]
},
vi.fn()
]),
useReveal: vi.fn(() => viewerMock),
use3dModels: vi.fn(() => [cadMock, cadMock]),
useSyncExternalLayersState: vi.fn(),
ModelLayerSelection: vi.fn(({ label }) => <div>{label}</div>)
};

const wrapper = (props: {
children: ReactNode;
dependencies?: LayersButtonDependencies;
}): ReactElement => {
const { children, dependencies = defaultDependencies } = props;
return (
<LayersButtonContext.Provider value={dependencies}>{children}</LayersButtonContext.Provider>
);
};

test('renders without crashing', () => {
const { getByRole } = render(<LayersButton {...defaultProps} />, {
wrapper: ({ children }: { children: ReactNode }) => wrapper({ children })
});

// Validate the presence of specific UI elements
expect(
getByRole('button', { name: 'Filter 3D resource layers' }).className.includes('cogs-button')
).toBe(true);
});

test('should update viewer models visibility when layersState changes', () => {
const ModelLayerSelection = vi.fn(({ label }) => <div>{label}</div>);
const newProps: LayersButtonDependencies & {
setLayersState: typeof defaultProps.setLayersState;
} = {
setLayersState: defaultProps.setLayersState,
...defaultDependencies,
useModelHandlers: vi.fn((): [ModelLayerHandlers, () => void] => [
{
cadHandlers: [mockCadHandler],
pointCloudHandlers: [mockPointCloudHandler],
image360Handlers: [mockImage360Handler]
},
() => {}
]),
useSyncExternalLayersState: vi.fn(),
ModelLayerSelection
};

const { rerender } = render(<LayersButton {...defaultProps} />, {
wrapper: ({ children }) => wrapper({ children, dependencies: newProps })
});

// Change layersState
const newLayersState = {
cadLayers: [{ revisionId: 456, applied: false, index: 0 }],
pointCloudLayers: [{ revisionId: 123, applied: true, index: 0 }],
image360Layers: [{ siteId: 'site-id', applied: false }]
};
if (newProps.setLayersState !== undefined) {
newProps.setLayersState(newLayersState);
}

// Re-render with the updated state
rerender(<LayersButton {...defaultProps} layersState={newLayersState} />);

mockCadHandler.setVisibility(newLayersState.cadLayers[0].applied);
mockPointCloudHandler.setVisibility(newLayersState.pointCloudLayers[0].applied);
mockImage360Handler.setVisibility(newLayersState.image360Layers[0].applied);

expect(mockCadHandler.visible()).toBe(false);
expect(mockPointCloudHandler.visible()).toBe(true);
expect(mockImage360Handler.visible()).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,14 @@
* Copyright 2024 Cognite AS
*/

import { type Dispatch, type SetStateAction, useCallback, type ReactElement } from 'react';
import { useTranslation } from '../../i18n/I18n';
import { type UpdateModelHandlersCallback, useModelHandlers } from './useModelHandlers';
import { useSyncExternalLayersState } from './useSyncExternalLayersState';
import { type Dispatch, type SetStateAction, type ReactElement } from 'react';
import { SelectPanel } from '@cognite/cogs-lab';
import { Button, ChevronRightSmallIcon, IconWrapper, LayersIcon, Tooltip } from '@cognite/cogs.js';
import { type ModelHandler } from './ModelHandler';
import { useRenderTarget, useReveal } from '../../RevealCanvas/ViewerContext';
import { WholeLayerVisibilitySelectItem } from './WholeLayerVisibilitySelectItem';
import { ModelLayersList } from './ModelLayersList';
import { type DefaultLayersConfiguration, type LayersUrlStateParam } from './types';
import { Button, LayersIcon, Tooltip } from '@cognite/cogs.js';
import { useTranslation } from '../../i18n/I18n';
import { TOOLBAR_HORIZONTAL_PANEL_OFFSET } from '../../constants';
import { CommandsUpdater } from '../../../architecture/base/reactUpdaters/CommandsUpdater';
import { LabelWithShortcut } from '../../Architecture/LabelWithShortcut';
import { useLayersButtonViewModel } from './LayersButton.viewmodel';
import { type LayersUrlStateParam, type DefaultLayersConfiguration } from './types';

export type LayersButtonProps = {
layersState?: LayersUrlStateParam | undefined;
Expand All @@ -29,26 +23,12 @@ export const LayersButton = ({
defaultLayerConfiguration
}: LayersButtonProps): ReactElement => {
const { t } = useTranslation();
const viewer = useReveal();
const renderTarget = useRenderTarget();

const [modelLayerHandlers, update] = useModelHandlers(
const { modelLayerHandlers, updateCallback, ModelLayerSelection } = useLayersButtonViewModel(
setExternalLayersState,
defaultLayerConfiguration
defaultLayerConfiguration,
externalLayersState
);

useSyncExternalLayersState(
modelLayerHandlers,
externalLayersState,
setExternalLayersState,
update
);

const updateCallback = useCallback(() => {
update(viewer.models, viewer.get360ImageCollections());
CommandsUpdater.update(renderTarget);
}, [update]);

return (
<>
<SelectPanel
Expand All @@ -60,7 +40,11 @@ export const LayersButton = ({
<Tooltip
content={<LabelWithShortcut label={t({ key: 'LAYERS_FILTER_TOOLTIP' })} />}
placement="right">
<Button icon=<LayersIcon /> type="ghost" />
<Button
icon={<LayersIcon />}
type="ghost"
aria-label={t({ key: 'LAYERS_FILTER_TOOLTIP' })}
/>
</Tooltip>
</SelectPanel.Trigger>
<SelectPanel.Body>
Expand All @@ -86,49 +70,3 @@ export const LayersButton = ({
</>
);
};

const ModelLayerSelection = ({
label,
modelLayerHandlers,
update
}: {
label: string;
modelLayerHandlers: ModelHandler[];
update: UpdateModelHandlersCallback;
}): ReactElement => {
const isDisabled = modelLayerHandlers.length === 0;

const viewer = useReveal();
const updateCallback = useCallback(() => {
update(viewer.models, viewer.get360ImageCollections());
}, [update]);

return (
<SelectPanel
placement="right"
appendTo={'parent'}
hideOnOutsideClick={true}
openOnHover={!isDisabled}>
<SelectPanel.Trigger>
<WholeLayerVisibilitySelectItem
label={label}
modelLayerHandlers={modelLayerHandlers}
update={updateCallback}
trailingContent={
<IconWrapper size={16}>
<ChevronRightSmallIcon />
</IconWrapper>
}
disabled={isDisabled}
/>
</SelectPanel.Trigger>
<SelectPanel.Body>
<ModelLayersList
modelLayerHandlers={modelLayerHandlers}
update={updateCallback}
disabled={isDisabled}
/>
</SelectPanel.Body>
</SelectPanel>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*!
* Copyright 2025 Cognite AS
*/
import { type ReactElement, useContext, type Dispatch, type SetStateAction } from 'react';
import { LayersButtonContext } from './LayersButton.context';
import {
type ModelLayerHandlers,
type DefaultLayersConfiguration,
type LayersUrlStateParam
} from './types';
import { type ModelHandler } from './ModelHandler';

type UpdateCallback = () => void;

type ModelLayerSelectionProps = {
label: string;
modelLayerHandlers: ModelHandler[];
update: UpdateCallback;
};

type UseLayersButtonViewModelResult = {
modelLayerHandlers: ModelLayerHandlers;
updateCallback: UpdateCallback;
ModelLayerSelection: (props: ModelLayerSelectionProps) => ReactElement;
};

export function useLayersButtonViewModel(
setExternalLayersState: Dispatch<SetStateAction<LayersUrlStateParam | undefined>> | undefined,
defaultLayerConfiguration: DefaultLayersConfiguration | undefined,
externalLayersState: LayersUrlStateParam | undefined
): UseLayersButtonViewModelResult {
const {
useModelHandlers,
useSyncExternalLayersState,
ModelLayerSelection,
use3dModels,
useReveal
} = useContext(LayersButtonContext);

const [modelLayerHandlers, update] = useModelHandlers(
setExternalLayersState,
defaultLayerConfiguration,
useReveal(),
use3dModels()
);

useSyncExternalLayersState(
modelLayerHandlers,
externalLayersState,
setExternalLayersState,
update
);

return {
modelLayerHandlers,
updateCallback: update,
ModelLayerSelection
};
}
Loading

0 comments on commit 2434b36

Please sign in to comment.