From 9453c0d3ae9a3155ff30a980b47f065982a3ed8b Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Tue, 4 Feb 2025 09:13:41 +0100 Subject: [PATCH 01/15] Adding tree --- .../src/advanced-tree-view/README.md | 117 +++++ .../src/advanced-tree-view/index.ts | 29 ++ .../model/get-children-as-array.ts | 19 + .../advanced-tree-view/model/i-lazy-loader.ts | 70 +++ .../model/tree-node-functions.ts | 130 +++++ .../model/tree-node-type.ts | 43 ++ .../src/advanced-tree-view/model/tree-node.ts | 491 ++++++++++++++++++ .../src/advanced-tree-view/model/types.ts | 27 + .../storybook-cad/cad-nodes-loader.ts | 188 +++++++ .../storybook-cad/cad-tree-node.ts | 115 ++++ .../storybook-cad/cad-tree-view-utils.ts | 10 + .../storybook-cad/cad-tree-view.stories.tsx | 121 +++++ .../storybook-cad/cognite-client-mock.ts | 156 ++++++ .../storybook-cad/cognite-client.ts | 30 ++ .../storybook-cad/i-cognite-client.ts | 18 + .../advanced-tree-view/storybook-cad/types.ts | 14 + .../storybook/advanced-tree-view.stories.tsx | 176 +++++++ .../storybook/create-simple-mock.ts | 61 +++ .../storybook/lazy-loader-mock.ts | 130 +++++ .../src/advanced-tree-view/utilities/class.ts | 9 + .../view/advanced-tree-view-node.tsx | 174 +++++++ .../view/advanced-tree-view-props.ts | 42 ++ .../view/advanced-tree-view-utils.ts | 57 ++ .../view/advanced-tree-view.tsx | 39 ++ .../view/components/tree-view-caret.tsx | 51 ++ .../view/components/tree-view-checkbox.tsx | 44 ++ .../view/components/tree-view-icon.tsx | 18 + .../view/components/tree-view-info.tsx | 51 ++ .../view/components/tree-view-label.tsx | 41 ++ .../view/components/tree-view-load-more.tsx | 43 ++ .../view/components/tree-view-loading.tsx | 40 ++ .../src/advanced-tree-view/view/constants.ts | 16 + .../view/use-on-tree-node-update.ts | 21 + .../base/domainObjects/DomainObject.ts | 3 +- 34 files changed, 2593 insertions(+), 1 deletion(-) create mode 100644 react-components/src/advanced-tree-view/README.md create mode 100644 react-components/src/advanced-tree-view/index.ts create mode 100644 react-components/src/advanced-tree-view/model/get-children-as-array.ts create mode 100644 react-components/src/advanced-tree-view/model/i-lazy-loader.ts create mode 100644 react-components/src/advanced-tree-view/model/tree-node-functions.ts create mode 100644 react-components/src/advanced-tree-view/model/tree-node-type.ts create mode 100644 react-components/src/advanced-tree-view/model/tree-node.ts create mode 100644 react-components/src/advanced-tree-view/model/types.ts create mode 100644 react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts create mode 100644 react-components/src/advanced-tree-view/storybook-cad/cad-tree-node.ts create mode 100644 react-components/src/advanced-tree-view/storybook-cad/cad-tree-view-utils.ts create mode 100644 react-components/src/advanced-tree-view/storybook-cad/cad-tree-view.stories.tsx create mode 100644 react-components/src/advanced-tree-view/storybook-cad/cognite-client-mock.ts create mode 100644 react-components/src/advanced-tree-view/storybook-cad/cognite-client.ts create mode 100644 react-components/src/advanced-tree-view/storybook-cad/i-cognite-client.ts create mode 100644 react-components/src/advanced-tree-view/storybook-cad/types.ts create mode 100644 react-components/src/advanced-tree-view/storybook/advanced-tree-view.stories.tsx create mode 100644 react-components/src/advanced-tree-view/storybook/create-simple-mock.ts create mode 100644 react-components/src/advanced-tree-view/storybook/lazy-loader-mock.ts create mode 100644 react-components/src/advanced-tree-view/utilities/class.ts create mode 100644 react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx create mode 100644 react-components/src/advanced-tree-view/view/advanced-tree-view-props.ts create mode 100644 react-components/src/advanced-tree-view/view/advanced-tree-view-utils.ts create mode 100644 react-components/src/advanced-tree-view/view/advanced-tree-view.tsx create mode 100644 react-components/src/advanced-tree-view/view/components/tree-view-caret.tsx create mode 100644 react-components/src/advanced-tree-view/view/components/tree-view-checkbox.tsx create mode 100644 react-components/src/advanced-tree-view/view/components/tree-view-icon.tsx create mode 100644 react-components/src/advanced-tree-view/view/components/tree-view-info.tsx create mode 100644 react-components/src/advanced-tree-view/view/components/tree-view-label.tsx create mode 100644 react-components/src/advanced-tree-view/view/components/tree-view-load-more.tsx create mode 100644 react-components/src/advanced-tree-view/view/components/tree-view-loading.tsx create mode 100644 react-components/src/advanced-tree-view/view/constants.ts create mode 100644 react-components/src/advanced-tree-view/view/use-on-tree-node-update.ts diff --git a/react-components/src/advanced-tree-view/README.md b/react-components/src/advanced-tree-view/README.md new file mode 100644 index 0000000000..9772b23990 --- /dev/null +++ b/react-components/src/advanced-tree-view/README.md @@ -0,0 +1,117 @@ +## How to use the `AdvancedTreeView`: + +### Overview + +This tree contains the following features to be used: + +- Label (Required) +- Bold label +- Icon +- Color on icon +- Checkbox with 3 different states +- Disable/enable the checkbox +- Selection +- Lazy loading av nodes +- isVisibleInTree + +### Using the `TreeNodeType`. + +The type of the tree node is called [`TreeNodeType`](./model/tree-node-type.ts). +This is the minimum of what you will need as a tree node. + +You can either implement your own version of `TreeNodeType` and make all necessary methods or +use the default implementation `TreeNode` directly. + +### Using the `TreeNode` class: + +An implementation of `TreeNodeType` is [`TreeNode`](./model/tree-node.ts) and contains more, +like navigation in the tree, colors, icons etc. +You can build up the tree view by using the `TreeNode`, +by recursive adding children node to parent nodes. + +### Using the `AdvancedTreeView` component: + +There is 3 optional functions that can be set on the [`AdvancedTreeViewProps`](./view/advanced-tree-view-props.ts). + +- `onSelectNode` is called when a node is selected +- `onToggleNode` is called when a checkbox is toggled +- `onClickInfo` is called when the info is clicked, if undefined, no info icon will appear. + +There are some default implementations that can be used in the +file [`tree-node-functions.ts`](./model/tree-node-functions.ts). +Here different strategies for selection and checkboxes are implemented. + +There are also some other property for appearance of the tree in the properties. + +### Examples of how the `AdvancedTreeView` component can be used: + +(There are several other examples in Storybook) + +```typescript +function createTree(): TreeNode { + const root = new TreeNode(); + root.label = 'Root'; + root.isExpanded = true; + + for (let i = 1; i <= 100; i++) { + const parent = new TreeNode(); + parent.label = 'Parent ' + i; + parent.isExpanded = true; + root.addChild(parent); + + for (let j = 1; j <= 10; j++) { + const child = new TreeNode(); + child.label = 'Child ' + i + '.' + j; + parent.addChild(child); + } + } + return root; +} + +const root = createTree(); + +; +``` + +### Lazy loading + +Lazy loading is optional. It will be applied when the `loader` property in the `AdvancedTreeViewProps` is set. +This property is a refer to the interface [`ILazyLoader`](./model/i-lazy-loader.ts). Look in the file for +description of the methods. + +There is two methods that need to be implemented: + +- `root` get the root of the tree +- `loadChildren` load the children of a parent +- `loadSiblings` load the siblings, typically when having a large number of children, + so loadChildren doesn't load all. + +When using the `loader` property in the `AdvancedTreeViewProps` is set, the `root` property is not used. +Instead the root is taken from `loader` itself. This is done in order to not double buffer the root in 2 +different places, since the root is needed in the loader. If the `root` is not set in the loader it will +call the method `loadInitialRoot` if it is implemented. + +When nodes are lazy loaded into the tree, the function `onNodeLoaded` will optionally be called. This method is +made in order to synchronize the checkboxes if needed. If for instance the parent checkbox is toggled on, it is +natural that the children checkbox should be toggled. The implementation below shows this: + +```typescript +onNodeLoaded(child: TreeNodeType, parent?: TreeNodeType): void { + if (parent === undefined) { + return; // No parent when root + } + if (parent.checkboxState === undefined) { + return; // No parent checkboxState exist + } + child.checkboxState = parent.checkboxState; + if (child.checkboxState == CheckboxState.All) { + // Set the node visible in the viewer + } + } +``` diff --git a/react-components/src/advanced-tree-view/index.ts b/react-components/src/advanced-tree-view/index.ts new file mode 100644 index 0000000000..055c6af776 --- /dev/null +++ b/react-components/src/advanced-tree-view/index.ts @@ -0,0 +1,29 @@ +/*! + * Copyright 2025 Cognite AS + */ +export { AdvancedTreeView } from './view/advanced-tree-view'; +export type { AdvancedTreeViewProps } from './view/advanced-tree-view-props'; +export type { GetIconFromIconNameFn } from './view/advanced-tree-view-props'; +export type { TreeNodeType } from './model/tree-node-type'; +export type { ILazyLoader } from './model/i-lazy-loader'; +export { TreeNode } from './model/tree-node'; +export type { + IconName, + IconColor, + CheckboxState, + TreeNodeAction, + OnNodeLoadedAction +} from './model/types'; + +export { + scrollToNode, + scrollToElementId, + scrollToFirst, + scrollToLast +} from './view/advanced-tree-view-utils'; +export { + onSingleSelectNode, + onMultiSelectNode, + onSimpleToggleNode, + onRecursiveToggleNode +} from './model/tree-node-functions'; diff --git a/react-components/src/advanced-tree-view/model/get-children-as-array.ts b/react-components/src/advanced-tree-view/model/get-children-as-array.ts new file mode 100644 index 0000000000..bc408586d2 --- /dev/null +++ b/react-components/src/advanced-tree-view/model/get-children-as-array.ts @@ -0,0 +1,19 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type ILazyLoader } from './i-lazy-loader'; +import { type TreeNodeType } from './tree-node-type'; + +export function getChildrenAsArray( + node: TreeNodeType, + loader?: ILazyLoader, + shouldUseExpanded = true +): TreeNodeType[] | undefined { + if (shouldUseExpanded && !node.isExpanded) { + return undefined; + } + if (node.getChildren(loader).next().value === undefined) { + return undefined; + } + return Array.from(node.getChildren(loader)); +} diff --git a/react-components/src/advanced-tree-view/model/i-lazy-loader.ts b/react-components/src/advanced-tree-view/model/i-lazy-loader.ts new file mode 100644 index 0000000000..c1137beb2a --- /dev/null +++ b/react-components/src/advanced-tree-view/model/i-lazy-loader.ts @@ -0,0 +1,70 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type TreeNodeType } from './tree-node-type'; +import { type OnNodeLoadedAction } from './types'; + +/** Interface for lazy loading tree nodes + * + * */ +export type ILazyLoader = { + /** + * Gets the root node of the tree. If loadInitialRoot is not called, the root node is undefined. + * + * @returns {TreeNodeType | undefined} The root node of the tree, or undefined if the root node is not set. + */ + root: TreeNodeType | undefined; + + /** + * Loads the initial root node of the tree. + * + * @returns {Promise} A promise that resolves to the root node of the tree. + * + * @remarks + * If the root node has already been loaded, it returns the cached root node. + * Otherwise, it should fetch the root node , + * sets it as the root node, marks it as expanded, and caches it for future use. + * + * @throws {Error} If there is an issue with fetching the root node from the SDK. + */ + loadInitialRoot?: () => Promise; + + /** + * Load children for for a given parent node + * @param node - The parent node for which to load children + * @returns A promise that resolves to an array of tree nodes or undefined. + */ + + loadChildren: (parent: TreeNodeType) => Promise; + + /** + * Load siblings for a given node (the sibling will be inserted just after the node) + * @param sibling - The sibling to where th other siblings are loaded from + * @returns A promise that resolves to an array of tree nodes or undefined. + */ + + loadSiblings: (sibling: TreeNodeType) => Promise; + + /** + * Forces a node to be present in the tree by an nodeIdentifier. If the node is already in the tree, + * it expands all its ancestors and returns the node. If the node is not in the tree, it fetches + * the ancestors of the node, inserts them into the tree, expands all ancestors of the new node, + * and returns the new node. + * + * @param nodeIdentifier - The identifier of the node to be forced into the tree. What the nodeIdentifier + * will be is up to the implementation of the lazy loader, since this method is not called by the tree view. + * @returns A promise that resolves to the tree node if it is successfully forced into the tree, + * or undefined if the root is undefined or the node could not be inserted. + */ + forceNodeInTree?: (nodeIdentifier: number) => Promise; + + /** + * Callback function that is triggered when a node is loaded and just after it is + * added/inserted into the tree. + * + * @param child - The child node that has been loaded. + * @param parent - (Optional) The parent node of the loaded child node. + */ + + onNodeLoaded?: OnNodeLoadedAction; +}; diff --git a/react-components/src/advanced-tree-view/model/tree-node-functions.ts b/react-components/src/advanced-tree-view/model/tree-node-functions.ts new file mode 100644 index 0000000000..d7725663cc --- /dev/null +++ b/react-components/src/advanced-tree-view/model/tree-node-functions.ts @@ -0,0 +1,130 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { TreeNode } from './tree-node'; +import { type TreeNodeType } from './tree-node-type'; +import { CheckboxState } from './types'; + +/** + * Handles the single selection of a tree node. + * If the node is selected, it will deselect all other nodes in the tree. + * If the node is not selected, it will select the node. + * + * @param node - The tree node to be selected or deselected. + */ +export function onSingleSelectNode(node: TreeNodeType): void { + if (!(node instanceof TreeNode)) { + return; + } + // Deselect all others + const root = node.getRoot(); + for (const descendant of root.getThisAndDescendants()) { + if (descendant !== node) { + descendant.isSelected = false; + } + } + node.isSelected = !node.isSelected; +} + +/** + * Handles the multi-selection of a tree node. + * Toggles the selection state of the node without affecting other nodes. + * + * @param node - The tree node to be selected or deselected. + */ +export function onMultiSelectNode(node: TreeNodeType): void { + if (!(node instanceof TreeNode)) { + return; + } + node.isSelected = !node.isSelected; +} + +/** + * Handles the independent checkbox state of a tree node. + * Toggles the checkbox state between `All` and `None`without affecting other nodes. + * + * @param node - The tree node whose checkbox state is to be toggled. + */ +export function onSimpleToggleNode(node: TreeNodeType): void { + if (!(node instanceof TreeNode)) { + return; + } + if (node.checkboxState === undefined) { + return; + } + if (node.checkboxState === CheckboxState.All) { + node.checkboxState = CheckboxState.None; + } else { + node.checkboxState = CheckboxState.All; + } +} + +/** + * Handles the event when a node's checkbox is clicked, toggling its state between `All` and `None`. + * It also updates the checkbox states of all its descendants and ancestors. + * + * @param node - The tree node that was clicked. + * + * @remarks + * - If the node's checkbox state is `All`, it will be set to `None`, and vice versa. + * - All descendants of the node will have their checkbox states updated to match the node's + * new state, unless their state is undefined. + * - All ancestors of the node will have their checkbox states recalculated, + * unless their state is undefined. + */ +export function onRecursiveToggleNode(node: TreeNodeType): void { + if (!(node instanceof TreeNode)) { + return; + } + if (node.checkboxState === undefined) { + return; + } + if (node.checkboxState === CheckboxState.All) { + node.checkboxState = CheckboxState.None; + } else { + node.checkboxState = CheckboxState.All; + } + // Recalculate all descendants and ancestors + for (const descendant of node.getDescendants()) { + if (descendant.checkboxState !== undefined) { + descendant.checkboxState = node.checkboxState; + } + } + for (const ancestor of node.getAncestors()) { + if (ancestor.checkboxState !== undefined) { + ancestor.checkboxState = calculateCheckboxState(ancestor); + } + } +} + +function calculateCheckboxState(node: TreeNodeType): CheckboxState | undefined { + let numCandidates = 0; + let numAll = 0; + let numNone = 0; + + for (const child of node.getChildren()) { + const checkboxState = child.checkboxState; + if (child.isCheckboxEnabled !== true || checkboxState === undefined) { + continue; + } + numCandidates += 1; + if (checkboxState === CheckboxState.All) { + numAll++; + } else if (checkboxState === CheckboxState.None) { + numNone++; + } + if (numNone < numCandidates && numCandidates < numAll) { + return CheckboxState.Some; // Optimization by early return + } + } + if (numCandidates === 0) { + return node.checkboxState; + } + if (numCandidates === numAll) { + return CheckboxState.All; + } + if (numCandidates === numNone) { + return CheckboxState.None; + } + return CheckboxState.Some; +} diff --git a/react-components/src/advanced-tree-view/model/tree-node-type.ts b/react-components/src/advanced-tree-view/model/tree-node-type.ts new file mode 100644 index 0000000000..09de30b69e --- /dev/null +++ b/react-components/src/advanced-tree-view/model/tree-node-type.ts @@ -0,0 +1,43 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type ILazyLoader } from './i-lazy-loader'; +import { type TreeNodeAction, type CheckboxState, type IconColor, type IconName } from './types'; + +export type TreeNodeType = { + id: string; // Returns the unique id of the node + + // Required Appearance + label: string; // Returns the label + isSelected: boolean; // Returns true if it is selected + isExpanded: boolean; // Returns true if expanded + isVisibleInTree?: boolean; // Returns true if the label should be rendered in bold font + + // Optional Appearance + isParent: boolean; // Returns true if this node has children (loaded or not loaded) + hasBoldLabel?: boolean; // Returns true if the label should be rendered in bold font + icon?: IconName; // Returns the icon, undefined is no icon + iconColor?: IconColor; // undefined means default color, normally black + isCheckboxEnabled?: boolean; // True if checkbox is enabled + checkboxState?: CheckboxState; // Undefined it no checkbox + + // For lazy loading + needLoadSiblings?: boolean; // Returns true if this node has more siblings to be loaded + isLoadingChildren?: boolean; // Returns true if this node is loading children now + isLoadingSiblings?: boolean; // Returns true if this node is loading siblings now + + /** + * (Required) Get the children of this node. If the children are not loaded, + * the lazy loader loader will optionally be used to load the children. + */ + getChildren: (loader?: ILazyLoader) => Generator; + + /** + * The siblings will be inserted just after the this node. + */ + loadSiblings?: (loader: ILazyLoader) => Promise; + + // Add or remove listener functions for updating. + addTreeNodeListener?: (listener: TreeNodeAction) => void; + removeTreeNodeListener?: (listener: TreeNodeAction) => void; +}; diff --git a/react-components/src/advanced-tree-view/model/tree-node.ts b/react-components/src/advanced-tree-view/model/tree-node.ts new file mode 100644 index 0000000000..8a0985d66f --- /dev/null +++ b/react-components/src/advanced-tree-view/model/tree-node.ts @@ -0,0 +1,491 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type Class, isInstanceOf } from '../utilities/class'; + +import { type ILazyLoader } from './i-lazy-loader'; +import { type TreeNodeType } from './tree-node-type'; +import { CheckboxState, type TreeNodeAction, type IconColor, type IconName } from './types'; + +/** + * Represents a node in a tree structure. + * This is the default Implements the TreeNodeType interface and can be reused + * By default it is a simple tree node with a label. + * in most cases. + */ +export class TreeNode implements TreeNodeType { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _id?: string; + private _label: string = ''; + private _icon: IconName = undefined; + private _iconColor: string | undefined = undefined; + private _isSelected: boolean = false; + private _checkboxState: CheckboxState | undefined = undefined; + private _isExpanded: boolean = false; + private _isCheckboxEnabled: boolean = true; + private _hasBoldLabel: boolean = false; + private _isLoadingChildren: boolean = false; + private _isLoadingSiblings: boolean = false; + private _needLoadChildren = false; + private _needLoadSiblings = false; + + protected _children: TreeNode[] | undefined = undefined; + protected _parent: TreeNode | undefined = undefined; + + // ================================================== + // INSTANCE PROPERTIES (Some are implementation of TreeNodeType) + // ================================================== + + public get id(): string { + if (this._id === undefined) { + this._id = TreeNode.generateId(); + } + return this._id; + } + + public get label(): string { + return this._label; + } + + public set label(value: string) { + if (this._label !== value) { + this._label = value; + this.update(); + } + } + + public get hasBoldLabel(): boolean { + return this._hasBoldLabel; + } + + public set hasBoldLabel(value: boolean) { + if (this._hasBoldLabel !== value) { + this._hasBoldLabel = value; + this.update(); + } + } + + public get icon(): IconName { + return this._icon; + } + + public set icon(value: IconName) { + if (this._icon !== value) { + this._icon = value; + this.update(); + } + } + + public get iconColor(): IconColor { + return this._iconColor; + } + + public set iconColor(value: IconColor) { + if (this._iconColor !== value) { + this._iconColor = value; + this.update(); + } + } + + public get isSelected(): boolean { + return this._isSelected; + } + + public set isSelected(value: boolean) { + if (this._isSelected !== value) { + this._isSelected = value; + this.update(); + } + } + + public get isExpanded(): boolean { + return this._isExpanded; + } + + public set isExpanded(value: boolean) { + if (this._isExpanded !== value) { + this._isExpanded = value; + this.update(); + } + } + + public get isCheckboxEnabled(): boolean { + return this._isCheckboxEnabled; + } + + public set isCheckboxEnabled(value: boolean) { + if (this._isCheckboxEnabled !== value) { + this._isCheckboxEnabled = value; + this.update(); + } + } + + public get checkboxState(): CheckboxState | undefined { + return this._checkboxState; + } + + public set checkboxState(value: CheckboxState | undefined) { + if (this._checkboxState !== value) { + this._checkboxState = value; + this.update(); + } + } + + public get needLoadChildren(): boolean { + return this._needLoadChildren; + } + + public set needLoadChildren(value: boolean) { + if (this._needLoadChildren !== value) { + this._needLoadChildren = value; + this.update(); + } + } + + public get needLoadSiblings(): boolean { + return this._needLoadSiblings; + } + + public set needLoadSiblings(value: boolean) { + if (this._needLoadSiblings !== value) { + this._needLoadSiblings = value; + this.update(); + } + } + + public get isLoadingChildren(): boolean { + return this._isLoadingChildren; + } + + public set isLoadingChildren(value: boolean) { + if (this._isLoadingChildren !== value) { + this._isLoadingChildren = value; + this.update(); + } + } + + public get isLoadingSiblings(): boolean { + return this._isLoadingSiblings; + } + + public set isLoadingSiblings(value: boolean) { + if (this._isLoadingSiblings !== value) { + this._isLoadingSiblings = value; + this.update(); + } + } + + public get isParent(): boolean { + if (this.needLoadChildren) { + return true; + } + return this._children !== undefined && this._children.length > 0; + } + + public get parent(): TreeNode | undefined { + return this._parent; + } + + public get children(): TreeNode[] | undefined { + return this._children; + } + + // ================================================== + // VIRTUAL METHODS: To be overridden + // ================================================== + + public areEqual(child: TreeNode): boolean { + return this === child; + } + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + // eslint-disable-next-line @typescript-eslint/prefer-return-this-type + public getRoot(): TreeNode { + if (this.parent !== undefined) { + return this.parent.getRoot(); + } + return this; + } + + public getLastChild(): TreeNode | undefined { + if (this._children === undefined) { + return undefined; + } + return this._children[this._children.length - 1]; + } + + // ================================================== + // INSTANCE METHODS: Selection and checked nodes + // ================================================== + + public getSelectedNodes(): TreeNode[] { + const nodes: TreeNode[] = []; + for (const child of this.getThisAndDescendants()) { + if (child.isSelected) { + nodes.push(child); + } + } + return nodes; + } + + public getCheckedNodes(): TreeNode[] { + const nodes: TreeNode[] = []; + for (const child of this.getThisAndDescendants()) { + if (child.checkboxState === CheckboxState.All) { + nodes.push(child); + } + } + return nodes; + } + + public deselectAll(): void { + for (const descendant of this.getThisAndDescendants()) { + descendant.isSelected = false; + } + } + + public expandAllAncestors(): void { + for (const ancestor of this.getAncestors()) { + ancestor.isExpanded = true; + } + } + + // ================================================== + // INSTANCE METHODS: Iterators + // ================================================== + + public *getChildren(loader?: ILazyLoader): Generator { + if (this.isLoadingChildren) { + loader = undefined; + } + const canLoad = this.isParent; + if (canLoad && loader !== undefined && this.needLoadChildren) { + void this.loadChildren(loader); + } + if (this._children === undefined) { + return; + } + for (const child of this._children) { + yield child; + } + } + + public *getChildrenByType(classType: Class): Generator { + for (const child of this.getChildren()) { + if (isInstanceOf(child, classType)) { + yield child; + } + } + } + + public *getDescendants(): Generator { + for (const child of this.getChildren()) { + yield child; + yield* child.getDescendants(); + } + } + + public *getExpandedDescendants(): Generator { + if (!this.isExpanded) { + return; + } + for (const child of this.getChildren()) { + yield child; + yield* child.getDescendants(); + } + } + + public *getDescendantsByType(classType: Class): Generator { + for (const descendant of this.getDescendants()) { + if (isInstanceOf(descendant, classType)) { + yield descendant; + } + } + } + + public *getThisAndDescendants(): Generator { + yield this; + for (const descendant of this.getDescendants()) { + yield descendant; + } + } + + public *getThisAndDescendantsByType( + classType: Class + ): Generator { + for (const descendant of this.getThisAndDescendants()) { + if (isInstanceOf(descendant, classType)) { + yield descendant; + } + } + } + + public *getAncestors(): Generator { + let ancestor = this.parent; + while (ancestor !== undefined) { + yield ancestor; + ancestor = ancestor.parent; + } + } + + public *getAncestorsByType(classType: Class): Generator { + let ancestor = this.parent; + while (ancestor !== undefined) { + if (!isInstanceOf(ancestor, classType)) { + break; + } + yield ancestor; + ancestor = ancestor.parent; + } + } + + public *getThisAndAncestorsByType( + classType: Class + ): Generator { + if (!isInstanceOf(this, classType)) { + return; + } + yield this; + for (const ancestor of this.getAncestorsByType(classType)) { + yield ancestor; + } + } + + // ================================================== + // INSTANCE METHODS: Parent child relationship + // ================================================== + + public addChild(child: TreeNode): void { + if (this._children === undefined) { + this._children = []; + } + this._children.push(child); + child._parent = this; + this.update(); + } + + public insertChild(index: number, child: TreeNode): void { + if (this._children === undefined) { + this._children = []; + } + insert(this._children, index, child); + child._parent = this; + this.update(); + } + + // ================================================== + // INSTANCE METHODS: Loading + // ================================================== + + private async loadChildren(loader: ILazyLoader): Promise { + this.isLoadingChildren = true; + const children = await loader.loadChildren(this); + this.isLoadingChildren = false; + if (children === undefined || children.length === 0) { + return; + } + if (this._children === undefined) { + this._children = []; + } + if (children !== undefined) { + for (const child of children) { + if (!(child instanceof TreeNode)) { + continue; + } + this.addChild(child); + loader.onNodeLoaded?.(child, this); + } + } + this.needLoadChildren = false; + } + + public async loadSiblings(loader: ILazyLoader): Promise { + this.isLoadingSiblings = true; + const siblings = await loader.loadSiblings(this); + this.isLoadingSiblings = false; + if (siblings === undefined || siblings.length === 0) { + return; + } + const parent = this.parent; + if (parent === undefined || parent._children === undefined) { + return; + } + const children = parent._children; + let index = children.indexOf(this); + if (index === undefined || index < 0) { + return; + } + for (const child of siblings) { + if (!(child instanceof TreeNode)) { + continue; + } + if (parent.hasChild(child)) { + continue; + } + index++; + parent.insertChild(index, child); + loader.onNodeLoaded?.(child, parent); + } + this.needLoadSiblings = false; + parent.update(); + } + + public hasChild(child: TreeNode): boolean { + if (this._children === undefined) { + return false; + } + return this._children.some((existingChild) => existingChild.areEqual(child)); + } + + // ================================================== + // INSTANCE METHODS: Event listeners + // ================================================== + + private readonly _listeners: TreeNodeAction[] = []; + + public addTreeNodeListener(listener: TreeNodeAction): void { + this._listeners.push(listener); + } + + public removeTreeNodeListener(listener: TreeNodeAction): void { + remove(this._listeners, listener); + } + + public update(): void { + for (const listener of this._listeners) { + listener(this); + } + } + + // ================================================== + // STATIC METHODS: + // ================================================== + + public static generateId(): string { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(); + } +} + +function insert(array: T[], index: number, element: T): void { + array.splice(index, 0, element); +} + +function removeAt(array: T[], index: number): void { + array.splice(index, 1); +} + +function remove(array: T[], element: T): boolean { + // Return true if changed + const index = array.indexOf(element); + if (index < 0) { + return false; + } + removeAt(array, index); + return true; +} diff --git a/react-components/src/advanced-tree-view/model/types.ts b/react-components/src/advanced-tree-view/model/types.ts new file mode 100644 index 0000000000..7a909ff492 --- /dev/null +++ b/react-components/src/advanced-tree-view/model/types.ts @@ -0,0 +1,27 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type TreeNodeType } from './tree-node-type'; + +export type IconName = string | undefined; + +export type IconColor = string | undefined; + +export enum CheckboxState { + All, + Some, + None +} + +export type TreeNodeAction = (node: TreeNodeType) => void; + +/** + * This defines a type alias LoadNodesAction for a function. This type is likely used to represent asynchronous actions for loading nodes in + * the tree view. + * @param node - The parent or the sibling node to load siblings for. + * @param boolean - True of children should be loaded, false if the sibling + * @return A Promise that resolves to an array of TreeNodeType objects or undefined. + * @beta + */ + +export type OnNodeLoadedAction = (child: TreeNodeType, parent?: TreeNodeType) => void; diff --git a/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts b/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts new file mode 100644 index 0000000000..53a69f8576 --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts @@ -0,0 +1,188 @@ +/*! + * Copyright 2025 Cognite AS + */ +import type { Node3D } from '@cognite/sdk'; + +import { type TreeNodeType, type ILazyLoader } from '..'; + +import { CadTreeNode } from './cad-tree-node'; +import { type ICogniteClient } from './i-cognite-client'; +import { type RevisionId, type SubsetOfNode3D } from './types'; + +export class CadNodesLoader implements ILazyLoader { + private readonly _sdk: ICogniteClient; + private readonly _revisionId: RevisionId; + private _root: CadTreeNode | undefined; + private readonly _listNodesLimit: number; + + public get root(): TreeNodeType | undefined { + return this._root; + } + + constructor(sdk: ICogniteClient, revisionId: RevisionId, listNodesLimit = 100) { + this._sdk = sdk; + this._revisionId = revisionId; + this._listNodesLimit = listNodesLimit; + } + + public onNodeLoaded(_child: TreeNodeType, _parent?: TreeNodeType): void {} + + public async loadInitialRoot(): Promise { + if (this._root !== undefined) { + return this._root; + } + const rootNodeObjResponse = await this._sdk.list3DNodes( + this._revisionId.modelId, + this._revisionId.revisionId, + { + depth: 0, + limit: 1 + } + ); + const rootNode = rootNodeObjResponse.items[0]; + const root = new CadTreeNode(rootNode); + root.isExpanded = true; + this._root = root; + return this._root; + } + + public async loadChildren(parent: TreeNodeType): Promise { + if (!(parent instanceof CadTreeNode)) { + return undefined; + } + const data = await this._sdk.list3DNodes( + this._revisionId.modelId, + this._revisionId.revisionId, + { + depth: 1, + limit: this._listNodesLimit, + nodeId: parent.nodeId + } + ); + data.items = data.items.filter((responseNode) => responseNode.id !== parent.nodeId); + + const treeNodes: TreeNodeType[] = data.items.map((node) => { + return new CadTreeNode(node); + }); + + if (data.nextCursor !== undefined) { + const lastNode = treeNodes[treeNodes.length - 1]; + if (lastNode instanceof CadTreeNode) { + lastNode.loadSiblingCursor = data.nextCursor; + } + } + return treeNodes; + } + + public async loadSiblings(sibling: TreeNodeType): Promise { + if (!(sibling instanceof CadTreeNode)) { + return undefined; + } + if (!(sibling.parent instanceof CadTreeNode)) { + throw new Error('Parent node id is undefined'); + } + const data = await this._sdk.list3DNodes( + this._revisionId.modelId, + this._revisionId.revisionId, + { + depth: 1, + limit: this._listNodesLimit, + nodeId: sibling.parent?.nodeId, + cursor: sibling.loadSiblingCursor + } + ); + const treeNodes: TreeNodeType[] = data.items.map((node) => { + return new CadTreeNode(node); + }); + + if (data.nextCursor !== undefined) { + const lastNode = treeNodes[treeNodes.length - 1]; + if (lastNode instanceof CadTreeNode) { + lastNode.loadSiblingCursor = data.nextCursor; + } + } + return treeNodes; + } + + public async forceNodeInTree(nodeId: number): Promise { + const root = this._root; + if (root === undefined) { + return undefined; + } + const treeNode = root.getThisOrDescendantByNodeId(nodeId); + if (treeNode !== undefined) { + treeNode.expandAllAncestors(); + return treeNode; // already in the tree + } + return await this.list3DNodeAncestors(nodeId).then((cdfNodes: Node3D[]) => { + if (root === undefined) { + return undefined; + } + const newTreeNode = this.insertAncestors(root, cdfNodes); + if (newTreeNode === undefined) { + return undefined; // This should not happen + } + // A new now is created + newTreeNode.expandAllAncestors(); + return newTreeNode; + }); + } + + // ================================================== + // INSTANCE METHODS: Misc + // ================================================== + + private async list3DNodeAncestors(nodeId: number): Promise { + const data = await this._sdk.list3DNodeAncestors( + this._revisionId.modelId, + this._revisionId.revisionId, + nodeId + ); + return data.items; + } + + private insertAncestors( + root: CadTreeNode, + newCdfNodes: SubsetOfNode3D[] + ): CadTreeNode | undefined { + // Returns the last create node + // Note: New nodes are always added last among the children + if (newCdfNodes.length === 0) { + return undefined; + } + // Check the first, it must be the root + const cdfRoot = newCdfNodes[0]; + if (cdfRoot.id !== root.nodeId) { + throw new Error('The root node is not the same as the current node'); + } + // eslint-disable-next-line @typescript-eslint/no-this-alias + let parent = root; + for (let i = 1; i < newCdfNodes.length; i++) { + const cdfNode = newCdfNodes[i]; + + const child = parent.getChildByNodeId(cdfNode.id); + if (child !== undefined) { + parent = child; // The node exist in the tree + continue; + } + // Create a new node + const newNode = new CadTreeNode(cdfNode); + const lastChild = parent.getLastChild() as CadTreeNode; + + // Give the cursor from the last child to the new node + if (lastChild?.needLoadSiblings) { + newNode.loadSiblingCursor = lastChild.loadSiblingCursor; + lastChild.loadSiblingCursor = undefined; + } + parent.addChild(newNode); + if (parent.needLoadChildren) { + parent.needLoadChildren = false; + parent.needLoadSiblings = true; + } + newNode.needLoadSiblings = true; + this.onNodeLoaded?.(newNode, parent); + parent = newNode; + } + return parent; + } +} diff --git a/react-components/src/advanced-tree-view/storybook-cad/cad-tree-node.ts b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-node.ts new file mode 100644 index 0000000000..41fe26a5d0 --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-node.ts @@ -0,0 +1,115 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { TreeNode } from '..'; + +import { type SubsetOfNode3D } from './types'; + +export class CadTreeNode extends TreeNode { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _nodeId: number; + private readonly _treeIndex: number; + private _loadSiblingCursor?: string; + + // ================================================== + // CONSTRUCTORS + // ================================================== + + constructor(node: SubsetOfNode3D) { + super(); + this._nodeId = node.id; + this._treeIndex = node.treeIndex; + this.label = node.name === '' ? node.id.toString() : node.name; + this.needLoadChildren = node.subtreeSize > 1; + } + + // ================================================== + // Getter and setters + // ================================================== + + public override get id(): string { + // AAA + return CadTreeNode.treeIndexToString(this.treeIndex); + } + + public get nodeId(): number { + return this._nodeId; + } + + public get treeIndex(): number { + return this._treeIndex; + } + + public get loadSiblingCursor(): string | undefined { + return this._loadSiblingCursor; + } + + public set loadSiblingCursor(value: string | undefined) { + this._loadSiblingCursor = value; + this.needLoadSiblings = value !== undefined; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override areEqual(child: TreeNode): boolean { + if (!(child instanceof CadTreeNode)) { + return false; + } + return child.nodeId === this.nodeId && child.treeIndex === this.treeIndex; + } + + // ================================================== + // INSTANCE METHODS: Access methods + // ================================================== + + public getChildByNodeId(nodeId: number): CadTreeNode | undefined { + for (const child of this.getChildrenByType(CadTreeNode)) { + if (child.nodeId === nodeId) { + return child; + } + } + } + + public getThisOrDescendantByNodeId(nodeId: number): CadTreeNode | undefined { + for (const descendant of this.getThisAndDescendantsByType(CadTreeNode)) { + if (descendant.nodeId === nodeId) { + return descendant; + } + } + } + + public getThisOrDescendantByTreeIndex(treeIndex: number): CadTreeNode | undefined { + for (const descendant of this.getThisAndDescendantsByType(CadTreeNode)) { + if (descendant.treeIndex === treeIndex) { + return descendant; + } + } + } + + public *getThisOrDescendantsByTreeIndices(treeIndices: number[]): Generator { + for (const descendant of this.getThisAndDescendantsByType(CadTreeNode)) { + const index = treeIndices.indexOf(descendant.treeIndex); + if (index >= 0) { + yield descendant; + } + } + } + + // ================================================== + // STATIC METHODS: + // ================================================== + + /** + * This function ensure same conversion of treeIndex to string + * @param treeIndex - The tree index to convert to string + * @returns The tree index as a string + */ + public static treeIndexToString(treeIndex: number): string { + return treeIndex.toString(); + } +} diff --git a/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view-utils.ts b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view-utils.ts new file mode 100644 index 0000000000..8f8dab0eb3 --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view-utils.ts @@ -0,0 +1,10 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { scrollToElementId } from '..'; + +import { CadTreeNode } from './cad-tree-node'; + +export function scrollToTreeIndex(container: HTMLElement | undefined, treeIndex: number): void { + scrollToElementId(container, CadTreeNode.treeIndexToString(treeIndex)); +} diff --git a/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view.stories.tsx b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view.stories.tsx new file mode 100644 index 0000000000..39298045fb --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view.stories.tsx @@ -0,0 +1,121 @@ +/*! + * Copyright 2025 Cognite AS + */ +import React, { useRef } from 'react'; + +import styled from 'styled-components'; + +import { type Meta, type StoryObj } from '@storybook/react'; + +import { Button } from '@cognite/cogs-core'; + +import { + AdvancedTreeView, + onRecursiveToggleNode, + onSingleSelectNode, + scrollToFirst, + scrollToLast, + scrollToNode +} from '..'; + +import { CadNodesLoader } from './cad-nodes-loader'; +import { CogniteClientMock } from './cognite-client-mock'; + +// Note: This simulate the behavior of the real CadTreeNode. Can not use it here +// because of not connection to CDF. + +const meta: Meta = { + component: AdvancedTreeView, + title: 'Components / AdvancedTreeView (Cad)' +}; + +type Story = StoryObj; + +export default meta; + +const stk = new CogniteClientMock(); +const baseLoader = new CadNodesLoader(stk, { revisionId: 1, modelId: 1 }, 10); + +export const base: Story = { + name: 'base', + render: () => { + return ( + + ); + } +}; + +const loader = new CadNodesLoader(stk, { revisionId: 1, modelId: 1 }, 10); + +export const Main: Story = { + name: 'main', + render: () => { + const containerRef = useRef(null); + return ( +
+ { + scrollToFirst(containerRef?.current ?? undefined, loader.root); + }}> + Scroll to first + + { + scrollToLast(containerRef?.current ?? undefined, loader.root); + }}> + Scroll to last + + { + const nodeId = stk.getRandomNodeId(); + await loader.forceNodeInTree(nodeId).then((selectedNode) => { + if (selectedNode === undefined) { + return; + } + onSingleSelectNode(selectedNode); + scrollToNode(containerRef?.current ?? undefined, selectedNode); + }); + }}> + Test Random Insert + + + + +
+ ); + } +}; + +const StyledButton = styled(Button)` + margin: 8px; +`; + +const Container = styled.div` + zindex: 1000; + padding: 16px; + display: flex; + height: 600px; + border-radius: 10px; + box-shadow: 0px 1px 8px #4f52681a; + overflow-x: auto; + overflow-y: auto; +`; diff --git a/react-components/src/advanced-tree-view/storybook-cad/cognite-client-mock.ts b/react-components/src/advanced-tree-view/storybook-cad/cognite-client-mock.ts new file mode 100644 index 0000000000..18c990685e --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook-cad/cognite-client-mock.ts @@ -0,0 +1,156 @@ +/*! + * Copyright 2025 Cognite AS + */ +import type { List3DNodesQuery, Node3D } from '@cognite/sdk'; + +import { CadTreeNode } from './cad-tree-node'; +import { type ICogniteClient } from './i-cognite-client'; + +const SLEEP_DURATION = 200; + +export class CogniteClientMock implements ICogniteClient { + public async list3DNodes( + _modelId: number, + _revisionId: number, + query: List3DNodesQuery + ): Promise<{ items: Node3D[]; nextCursor?: string }> { + await sleep(SLEEP_DURATION); + const nodeId = query.nodeId; + const prevCursor = query.cursor; + if (nodeId === undefined) { + return { items: [getNode3D(TREE)] }; + } + const parent = TREE.getThisOrDescendantByNodeId(nodeId); + const nodes: Node3D[] = []; + let nextCursor: string | undefined; + if (parent === undefined) { + nodes.push(getNode3D(TREE)); + } else { + let inside = prevCursor === undefined; + for (const child of parent.getChildrenByType(CadTreeNode)) { + if (!inside) { + if (prevCursor === child.label) { + inside = true; // Cursor found + } else { + continue; + } + } + if (query.limit !== undefined && nodes.length >= query.limit) { + nextCursor = child.label; + break; // Limit reached + } + nodes.push(getNode3D(child)); + } + } + return { items: nodes, nextCursor }; + } + + public async list3DNodeAncestors( + _modelId: number, + _revisionId: number, + nodeId: number + ): Promise<{ items: Node3D[] }> { + await sleep(SLEEP_DURATION); + const node = TREE.getThisOrDescendantByNodeId(nodeId); + if (node === undefined) { + return { items: [] }; + } + const nodes: Node3D[] = []; + nodes.push(getNode3D(node)); + for (const ancestor of node.getAncestorsByType(CadTreeNode)) { + nodes.push(getNode3D(ancestor)); + } + nodes.reverse(); + return { items: nodes }; + } + + public getRandomNodeId(): number { + // AAA + const nodes = new Array(); + for (const descendant of TREE.getThisAndDescendants()) { + if (descendant instanceof CadTreeNode) { + nodes.push(descendant); + } + } + const index = getRandomIntByMax(nodes.length); + return nodes[index].nodeId; + } +} + +const TREE = createTree(); + +function createTree(): CadTreeNode { + let nodeId = 0; + let treeIndex = 0; + const root = createNode(nodeId++, treeIndex++); + + for (let i = 1; i <= 67; i++) { + const parent = createNode(nodeId++, treeIndex++); + root.addChild(parent); + for (let j = 1; j <= 260; j++) { + const child = createNode(nodeId++, treeIndex++); + parent.addChild(child); + for (let k = 1; k <= 145; k++) { + const leaf = createNode(nodeId++, treeIndex++); + child.addChild(leaf); + for (let k = 1; k <= 2; k++) { + const leafChild = createNode(nodeId++, treeIndex++); + leaf.addChild(leafChild); + } + } + } + } + return root; + + function createNode(id: number, treeIndex: number): CadTreeNode { + return new CadTreeNode({ + id, + name: 'Cad node ' + id, + treeIndex, + subtreeSize: -1 // No not need this value + }); + } +} + +function getRandomIntByMax(max: number): number { + return Math.floor(Math.random() * max); +} + +export async function sleep(durationInMs: number): Promise { + await new Promise((resolve) => setTimeout(resolve, durationInMs)); +} + +function getNode3D(node: CadTreeNode): Node3D { + return { + id: node.nodeId, + treeIndex: node.treeIndex, + subtreeSize: getSubTreeSize(node), + name: node.label, + parentId: getParentId(node), + depth: getDepth(node) + }; + + function getSubTreeSize(node: CadTreeNode): number { + let subtreeSize = 1; + for (const _ancestor of node.getDescendants()) { + subtreeSize++; + } + return subtreeSize; + } + + function getDepth(node: CadTreeNode): number { + let depth = 0; + for (const _ancestor of node.getAncestors()) { + depth++; + } + return depth; + } + + function getParentId(node: CadTreeNode): number { + if (node.parent instanceof CadTreeNode) { + const parent = node.parent; + return parent.nodeId; + } + return -1; + } +} diff --git a/react-components/src/advanced-tree-view/storybook-cad/cognite-client.ts b/react-components/src/advanced-tree-view/storybook-cad/cognite-client.ts new file mode 100644 index 0000000000..db3dc7256c --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook-cad/cognite-client.ts @@ -0,0 +1,30 @@ +/*! + * Copyright 2025 Cognite AS + */ +import type { CogniteClient, List3DNodesQuery, Node3D } from '@cognite/sdk'; + +import { type ICogniteClient } from './i-cognite-client'; + +export class MyCogniteClient implements ICogniteClient { + private readonly _sdk: CogniteClient; + + constructor(sdk: CogniteClient) { + this._sdk = sdk; + } + + public async list3DNodes( + modelId: number, + revisionId: number, + query: List3DNodesQuery + ): Promise<{ items: Node3D[]; nextCursor?: string }> { + return await this._sdk.revisions3D.list3DNodes(modelId, revisionId, query); + } + + public async list3DNodeAncestors( + modelId: number, + revisionId: number, + nodeId: number + ): Promise<{ items: Node3D[] }> { + return await this._sdk.revisions3D.list3DNodeAncestors(modelId, revisionId, nodeId); + } +} diff --git a/react-components/src/advanced-tree-view/storybook-cad/i-cognite-client.ts b/react-components/src/advanced-tree-view/storybook-cad/i-cognite-client.ts new file mode 100644 index 0000000000..8971913893 --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook-cad/i-cognite-client.ts @@ -0,0 +1,18 @@ +/*! + * Copyright 2025 Cognite AS + */ +import type { List3DNodesQuery, Node3D } from '@cognite/sdk'; + +export type ICogniteClient = { + list3DNodes: ( + modelId: number, + revisionId: number, + query: List3DNodesQuery + ) => Promise<{ items: Node3D[]; nextCursor?: string }>; + + list3DNodeAncestors: ( + modelId: number, + revisionId: number, + nodeId: number + ) => Promise<{ items: Node3D[] }>; +}; diff --git a/react-components/src/advanced-tree-view/storybook-cad/types.ts b/react-components/src/advanced-tree-view/storybook-cad/types.ts new file mode 100644 index 0000000000..07327ce4af --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook-cad/types.ts @@ -0,0 +1,14 @@ +/*! + * Copyright 2025 Cognite AS + */ +export type SubsetOfNode3D = { + id: number; + treeIndex: number; + subtreeSize: number; + name: string; +}; + +export type RevisionId = { + modelId: number; + revisionId: number; +}; diff --git a/react-components/src/advanced-tree-view/storybook/advanced-tree-view.stories.tsx b/react-components/src/advanced-tree-view/storybook/advanced-tree-view.stories.tsx new file mode 100644 index 0000000000..7ed3f2876c --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook/advanced-tree-view.stories.tsx @@ -0,0 +1,176 @@ +/*! + * Copyright 2025 Cognite AS + */ +import React from 'react'; + +import { type Meta, type StoryObj } from '@storybook/react'; + +import { + CopyIcon, + CubeIcon, + FlagIcon, + FolderIcon, + type IconProps, + PlaceholderIcon, + SnowIcon +} from '@cognite/cogs-icons'; + +import { + onMultiSelectNode, + onSingleSelectNode, + onSimpleToggleNode, + onRecursiveToggleNode +} from '../model/tree-node-functions'; +import { type TreeNodeType } from '../model/tree-node-type'; +import { type IconName } from '../model/types'; +import { AdvancedTreeView } from '../view/advanced-tree-view'; + +import { createSimpleMock } from './create-simple-mock'; +import { LazyLoaderMock } from './lazy-loader-mock'; + +const meta: Meta = { + component: AdvancedTreeView, + title: 'Components / AdvancedTreeView' +}; + +export default meta; + +type Story = StoryObj; + +const baseRoot = createSimpleMock({}); +export const base: Story = { + name: 'base', + render: () => +}; + +const rootWithHiddenRoot = createSimpleMock({}); +export const HideRoot: Story = { + name: 'hide root', + render: () => +}; + +const rootWithMaxLabelLength = createSimpleMock({}); +for (const node of rootWithMaxLabelLength.getThisAndDescendants()) { + node.label += ' is a very nice place to be'; +} + +export const MaxLabelLengthRoot: Story = { + name: 'max label length with tooltip', + render: () => +}; + +const rootWithSingleSelect = createSimpleMock({}); +export const SingleSelect: Story = { + name: 'single selection', + render: () => +}; + +const rootWithMultiSelect = createSimpleMock({}); +export const MultiSelect: Story = { + name: 'multi selection', + render: () => +}; + +const rootWithSimpleCB = createSimpleMock({ hasCheckboxes: true }); +export const SimpleCheckboxes: Story = { + name: 'simple checkboxes', + render: () => +}; + +const rootWithDisabledCB = createSimpleMock({ + hasCheckboxes: true, + hasDisabledCheckboxes: true +}); +export const SimpleCheckboxesWithSomeDisabled: Story = { + name: 'simple checkboxes, some disabled', + render: () => +}; + +const rootWithRecursiveCB = createSimpleMock({ hasCheckboxes: true }); +export const RecursiveCheckboxes: Story = { + name: 'recursive checkboxes', + render: () => +}; + +const rootWithIcon = createSimpleMock({}); +export const WithIcons: Story = { + name: 'icons', + render: () => +}; + +const rootWithColorIcon = createSimpleMock({ hasColors: true }); +export const WithColorIcons: Story = { + name: 'icons and colors', + render: () => ( + + ) +}; + +const rootWithColorIconBold = createSimpleMock({ + hasColors: true, + hasBoldLabels: true +}); +export const WithBoldLabels: Story = { + name: 'icons, colors and some bold labels', + render: () => ( + + ) +}; + +const infoRoot = createSimpleMock({}); +export const WithInfo: Story = { + name: 'info', + render: () => +}; + +const lazyLoaderMock1 = new LazyLoaderMock(true, false, 10); +export const LazyLoading: Story = { + name: 'lazy loading', + render: () => +}; + +const lazyLoaderMock2 = new LazyLoaderMock(true, true, 10); +export const LazyLoadingWithEverything: Story = { + name: 'lazy loading with everything', + render: () => ( + + ) +}; + +const lazyLoaderMock3 = new LazyLoaderMock(false, false, 100); +export const VeryLargeWithEverything: Story = { + name: 'tree with 1.000.000 nodes', + render: () => ( + + ) +}; + +function onClickInfo(node: TreeNodeType): void { + alert('You clicked: ' + node.label); +} + +function getIconFromIconName(icon: IconName): React.FC { + if (icon === 'Folder') { + return FolderIcon; + } else if (icon === 'Cube') { + return CubeIcon; + } else if (icon === 'Snow') { + return SnowIcon; + } else if (icon === 'Flag') { + return FlagIcon; + } else if (icon === 'Copy') { + return CopyIcon; + } + return PlaceholderIcon; +} diff --git a/react-components/src/advanced-tree-view/storybook/create-simple-mock.ts b/react-components/src/advanced-tree-view/storybook/create-simple-mock.ts new file mode 100644 index 0000000000..a32f8ff56b --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook/create-simple-mock.ts @@ -0,0 +1,61 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { TreeNode } from '../model/tree-node'; +import { CheckboxState } from '../model/types'; + +type SimpleMockArgs = { + hasColors?: boolean; + hasBoldLabels?: boolean; + hasCheckboxes?: boolean; + hasDisabledCheckboxes?: boolean; +}; + +export function createSimpleMock(args: SimpleMockArgs): TreeNode { + const root = new TreeNode(); + root.label = 'Europa'; + root.isExpanded = true; + + const germany = add(root, 'Germany', true); + add(germany, 'Frankfurt'); + const berlin = add(germany, 'Berlin'); + add(berlin, 'Mitte'); + + const norway = add(root, 'Norway', true); + add(norway, 'Bergen'); + add(norway, 'Oslo'); + + for (const node of root.getThisAndDescendants()) { + if (args.hasColors === true) { + // AAA + node.iconColor = getRandomColor(); + } + if (args.hasBoldLabels === true) { + // AAA + node.hasBoldLabel = Math.random() < 0.5; + } + if (args.hasDisabledCheckboxes === true) { + // AAA + node.isCheckboxEnabled = Math.random() < 0.5; + } + if (args.hasCheckboxes === true) { + // AAA + node.checkboxState = CheckboxState.None; + } + node.icon = node.isParent ? 'Folder' : 'Snow'; + } + return root; + + function add(parent: TreeNode, label: string, isExpanded = false): TreeNode { + const child = new TreeNode(); + child.label = label; + child.isExpanded = isExpanded; + parent.addChild(child); + return child; + } +} + +export function getRandomColor(): string { + const hue = Math.random() * 255; + return `hsl(${hue}, 100%, 50%)`; +} diff --git a/react-components/src/advanced-tree-view/storybook/lazy-loader-mock.ts b/react-components/src/advanced-tree-view/storybook/lazy-loader-mock.ts new file mode 100644 index 0000000000..dc67f325ef --- /dev/null +++ b/react-components/src/advanced-tree-view/storybook/lazy-loader-mock.ts @@ -0,0 +1,130 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type ILazyLoader } from '../model/i-lazy-loader'; +import { TreeNode } from '../model/tree-node'; +import { type TreeNodeType } from '../model/tree-node-type'; +import { CheckboxState } from '../model/types'; + +import { getRandomColor } from './create-simple-mock'; + +/** + * Creates a mock tree structure for testing purposes. + * + * @param lazyLoading - A boolean indicating whether child nodes should be lazily loaded. + * @returns A TreeNode representing the root of the mock tree. + */ + +export class LazyLoaderMock implements ILazyLoader { + private readonly _lazyLoading: boolean; + private readonly _withStyling: boolean; + private readonly _childrenCount: number; + + constructor(lazyLoading: boolean, withStyling: boolean, childrenCount: number) { + this._lazyLoading = lazyLoading; + this._withStyling = withStyling; + this._childrenCount = childrenCount; + } + + root: TreeNodeType | undefined; + public async loadInitialRoot(): Promise { + if (this.root !== undefined) { + return this.root; + } + const childrenCount = this._childrenCount; + const lazyLoading = this._lazyLoading; + + const root = new TreeNode(); + root.label = 'Root'; + root.isExpanded = true; + root.icon = 'Snow'; + + for (let i = 1; i <= childrenCount; i++) { + const parent = new TreeNode(); + parent.label = 'Folder ' + i; + parent.isExpanded = childrenCount < 20; + parent.icon = 'Folder'; + root.addChild(parent); + + for (let j = 1; j <= childrenCount; j++) { + const child = new TreeNode(); + child.label = 'Child ' + i + '.' + j; + child.icon = 'Copy'; + child.needLoadChildren = lazyLoading; + parent.addChild(child); + if (lazyLoading) { + continue; + } + for (let k = 1; k <= childrenCount; k++) { + const leaf = new TreeNode(); + leaf.label = 'Leaf ' + i + '.' + j + '.' + k; + leaf.icon = 'Cube'; + leaf.needLoadChildren = lazyLoading; + child.addChild(leaf); + } + } + } + if (this._withStyling) { + for (const node of root.getThisAndDescendants()) { + setRandomStyling(node); + } + } + this.root = root; + return root; + } + + public async loadChildren(parent: TreeNodeType): Promise { + return await this.loadNodes_(parent, true); + } + + public async loadSiblings(sibling: TreeNodeType): Promise { + return await this.loadNodes_(sibling, false); + } + + private async loadNodes_( + _node: TreeNodeType, + _loadChildren: boolean + ): Promise { + if (!this._lazyLoading) { + return undefined; + } + const name = 'Lazy ' + (_loadChildren ? 'Child ' : 'Sibling '); + + const promise = new Promise((resolve) => + setTimeout(() => { + const array: TreeNodeType[] = []; + const totalCount = 123; + const batchSize = 10; + + for (let i = 0; i < batchSize && i < totalCount; i++) { + const child = new TreeNode(); + child.label = name + getRandomIntByMax(1000); + child.icon = 'Cube'; + child.isExpanded = false; + child.needLoadSiblings = i === batchSize - 1; + + if (this._withStyling) { + setRandomStyling(child); + } + array.push(child); + } + resolve(array); + }, 2000) + ); + return await promise; + } +} + +function setRandomStyling(node: TreeNodeType): void { + if (!(node instanceof TreeNode)) { + return; + } + node.iconColor = getRandomColor(); + node.isCheckboxEnabled = Math.random() < 0.9; + node.hasBoldLabel = Math.random() < 0.1; + node.checkboxState = CheckboxState.None; +} + +function getRandomIntByMax(max: number): number { + return Math.floor(Math.random() * max); +} diff --git a/react-components/src/advanced-tree-view/utilities/class.ts b/react-components/src/advanced-tree-view/utilities/class.ts new file mode 100644 index 0000000000..cdf518cb99 --- /dev/null +++ b/react-components/src/advanced-tree-view/utilities/class.ts @@ -0,0 +1,9 @@ +/*! + * Copyright 2025 Cognite AS + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export type Class = Function & { prototype: T }; + +export function isInstanceOf(value: unknown, classType: Class): value is T { + return value instanceof classType; +} diff --git a/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx b/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx new file mode 100644 index 0000000000..70622e4e34 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx @@ -0,0 +1,174 @@ +/*! + * Copyright 2025 Cognite AS + */ +/* eslint-disable react/prop-types */ +import { type ReactElement, useReducer, useState } from 'react'; + +import { Colors } from '@cognite/cogs-core'; + +import { getChildrenAsArray } from '../model/get-children-as-array'; +import { type TreeNodeType } from '../model/tree-node-type'; + +import { type AdvancedTreeViewProps } from './advanced-tree-view-props'; +import { TreeViewCaret } from './components/tree-view-caret'; +import { TreeViewCheckbox } from './components/tree-view-checkbox'; +import { TreeViewIcon } from './components/tree-view-icon'; +import { TreeViewInfo } from './components/tree-view-info'; +import { TreeViewLabel } from './components/tree-view-label'; +import { TreeViewLoadMore } from './components/tree-view-load-more'; +import { TreeViewLoading } from './components/tree-view-loading'; +import { HORIZONTAL_SPACING, INDENTATION, VERTICAL_SPACING } from './constants'; +import { useOnTreeNodeUpdate } from './use-on-tree-node-update'; + +// ================================================== +// MAIN COMPONENT +// ================================================== + +export const AdvancedTreeViewNode = ({ + node: inputNode, + level, + props +}: { + node: TreeNodeType; + level: number; + props: AdvancedTreeViewProps; +}): ReactElement => { + // Props + const [isHover, setHover] = useState(false); + // This force to update the component when the node changes + // See https://coreui.io/blog/how-to-force-a-react-component-to-re-render/ + const [, forceUpdate] = useReducer((x) => x + 1, 0); + useOnTreeNodeUpdate(inputNode, () => { + forceUpdate(); + }); + if (inputNode.isVisibleInTree === false) { + return <>; + } + + const children = getChildrenAsArray(inputNode, props.loader); + const horizontalSpacing = HORIZONTAL_SPACING + 'px'; + const verticalSpacing = VERTICAL_SPACING + 'px'; + const marginLeft = level * INDENTATION + 'px'; + const backgroundColor = getBackgroundColor(inputNode, isHover); + const hasHover = props.hasHover ?? true; + const hasCheckboxes = props.hasCheckboxes ?? true; + const hasInfo = props.onClickInfo !== undefined; // AAA + const isLoadingChildren = inputNode.isLoadingChildren === true; // AAA + const hasLoadMore = + inputNode.isLoadingSiblings === false && + inputNode.needLoadSiblings === true && + inputNode.loadSiblings !== undefined; + + return ( +
+
{ + onHover(inputNode, true); + }} + onMouseLeave={() => { + onHover(inputNode, false); + }}> + + {hasCheckboxes && } +
{ + onSelectNode(inputNode); + event.stopPropagation(); + event.preventDefault(); + }}> + {props.getIconFromIconName !== undefined && inputNode.icon !== undefined && ( + + )} + {isLoadingChildren && } + {!isLoadingChildren && } +
+ {hasInfo && } +
+ {children !== undefined && + children.map((node) => ( + + ))} + {hasLoadMore && ( + + )} + {inputNode.isLoadingSiblings === true && } +
+ ); + + function onSelectNode(node: TreeNodeType): void { + if (props.onSelectNode === undefined) { + return; + } + props.onSelectNode(node); + } + + function onToggleNode(node: TreeNodeType): void { + if (node.isCheckboxEnabled !== true || node.checkboxState === undefined) { + return; + } + if (props.onToggleNode === undefined) { + return; + } + props.onToggleNode(node); + } + + function onExpandNode(node: TreeNodeType): void { + if (!node.isParent) { + return; + } + node.isExpanded = !node.isExpanded; + } + + function onLoadMore(node: TreeNodeType): void { + if (props.loader === undefined) { + return; + } + if (node.loadSiblings === undefined) { + return; + } + void node.loadSiblings(props.loader); + } + + function onHover(node: TreeNodeType, value: boolean): void { + if (!hasHover) { + return; + } + setHover(value); + } +}; + +function getBackgroundColor(node: TreeNodeType, hover: boolean): string | undefined { + if (node.isSelected) { + return Colors['surface--interactive--toggled-pressed']; + } + if (hover) { + return Colors['surface--interactive--hover']; + } + return undefined; +} + +function getTextColor(_node: TreeNodeType): string | undefined { + return Colors['text-icon--strong']; +} diff --git a/react-components/src/advanced-tree-view/view/advanced-tree-view-props.ts b/react-components/src/advanced-tree-view/view/advanced-tree-view-props.ts new file mode 100644 index 0000000000..0741115f76 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/advanced-tree-view-props.ts @@ -0,0 +1,42 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type IconProps } from '@cognite/cogs-icons'; + +import { type ILazyLoader } from '../model/i-lazy-loader'; +import { type TreeNodeType } from '../model/tree-node-type'; +import { type IconName, type TreeNodeAction } from '../model/types'; +import { type FC } from 'react'; + +export type AdvancedTreeViewProps = { + // Appearance + showRoot?: boolean; // Show root, default is true + hasHover?: boolean; // Default true, If this is set, it uses the hover color for the mouse over effect + hasCheckboxes?: boolean; // Default is true, but if the node has checkboxState == undefined, it will not be shown anyway + + // Labels are not translated. Should be done on the app side? + loadingLabel?: string; // Default is 'Loading...' + loadMoreLabel?: string; // Default is 'Load more...' + maxLabelLength?: number; // Max length of label before it is truncated and a tooltip will appear + + // Event handlers + onSelectNode?: TreeNodeAction; // Called when user select a node + onToggleNode?: TreeNodeAction; // Called when user toggle a node + onClickInfo?: TreeNodeAction; // Called when user click the info icon, if undefined, no info icon will appear + getIconFromIconName?: GetIconFromIconNameFn; // Function to get the icon for a node, if not set no icon will appear + + // The root node of the tree (used only if loader is not set) + root?: TreeNodeType; + + // Lazy loading manager (if this is set, the root above will be ignore, + // because the root is taken from the lazy loader) + loader?: ILazyLoader; +}; + +/* + * Convert between a string to the actual Cogs icon component. + * This is made to avoid references to Cogs in the data model + * so the data model can be independent on JXS. + */ + +export type GetIconFromIconNameFn = (icon: IconName) => FC; // AAA diff --git a/react-components/src/advanced-tree-view/view/advanced-tree-view-utils.ts b/react-components/src/advanced-tree-view/view/advanced-tree-view-utils.ts new file mode 100644 index 0000000000..9bb871dce6 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/advanced-tree-view-utils.ts @@ -0,0 +1,57 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { TreeNode } from '../model/tree-node'; +import { type TreeNodeType } from '../model/tree-node-type'; + +export function scrollToNode(container: HTMLElement | undefined, node: TreeNodeType): void { + if (node instanceof TreeNode) { + node.expandAllAncestors(); + } + scrollToElementId(container, node.id); +} + +export function scrollToElementId(container: HTMLElement | undefined, id: string): void { + if (container === undefined) { + return; + } + const element = document.getElementById(id); + if (element === null) { + console.error('Element is not found', id); + return; + } + const height = container.offsetHeight; + const top = element.offsetTop; + const newTop = Math.max(0, top - height / 2); + container.scroll({ top: newTop, behavior: 'smooth' }); +} + +export function scrollToFirst( + container: HTMLElement | undefined, + root: TreeNodeType | undefined +): void { + if (container === undefined) { + return; + } + if (root === undefined) { + return; + } + scrollToElementId(container, root.id); +} + +export function scrollToLast( + container: HTMLElement | undefined, + root: TreeNodeType | undefined +): void { + if (container === undefined) { + return; + } + if (!(root instanceof TreeNode)) { + return; + } + let lastNode = root; + for (const node of root.getThisAndDescendants()) { + lastNode = node; + } + scrollToNode(container, lastNode); +} diff --git a/react-components/src/advanced-tree-view/view/advanced-tree-view.tsx b/react-components/src/advanced-tree-view/view/advanced-tree-view.tsx new file mode 100644 index 0000000000..5d42dea280 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/advanced-tree-view.tsx @@ -0,0 +1,39 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { useState, type ReactElement } from 'react'; + +import { Loader } from '@cognite/cogs-core'; + +import { getChildrenAsArray } from '../model/get-children-as-array'; + +import { AdvancedTreeViewNode } from './advanced-tree-view-node'; +import { type AdvancedTreeViewProps } from './advanced-tree-view-props'; + +export const AdvancedTreeView = (props: AdvancedTreeViewProps): ReactElement => { + const id = 'advancedTreeView'; + const [root, setRoot] = useState(props.loader?.root ?? props.root); + + if (props.loader !== undefined && props.loader.loadInitialRoot !== undefined) { + void props.loader.loadInitialRoot().then(() => { + setRoot(props.loader?.root); + }); + } + if (root === undefined) { + return ; + } + if (props.showRoot !== false) { + return ; + } + const nodes = getChildrenAsArray(root, props.loader, false); + if (nodes === undefined) { + return <>; + } + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ); +}; diff --git a/react-components/src/advanced-tree-view/view/components/tree-view-caret.tsx b/react-components/src/advanced-tree-view/view/components/tree-view-caret.tsx new file mode 100644 index 0000000000..c6668c247f --- /dev/null +++ b/react-components/src/advanced-tree-view/view/components/tree-view-caret.tsx @@ -0,0 +1,51 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type ReactElement, useState } from 'react'; + +import { CaretDownIcon, CaretRightIcon } from '@cognite/cogs-icons'; + +import { type TreeNodeType } from '../../model/tree-node-type'; +import { type TreeNodeAction } from '../../model/types'; +import { type AdvancedTreeViewProps } from '../advanced-tree-view-props'; +import { CARET_COLOR, CARET_SIZE, HOVER_CARET_COLOR } from '../constants'; + +export const TreeViewCaret = ({ + node, + onClick +}: { + node: TreeNodeType; + onClick: TreeNodeAction; + props: AdvancedTreeViewProps; +}): ReactElement => { + const [isHover, setHover] = useState(false); + const color = getColor(isHover); + const size = CARET_SIZE + 'px'; + const style = { color, marginTop: '0px', width: size, height: size }; + + if (node.isParent) { + const Icon = node.isExpanded ? CaretDownIcon : CaretRightIcon; + return ( + { + onClick(node); + }} + onMouseEnter={() => { + setHover(true); + }} + onMouseLeave={() => { + setHover(false); + }} + style={style} + /> + ); + } + return
; +}; + +function getColor(isHoverOver: boolean): string | undefined { + if (isHoverOver) { + return HOVER_CARET_COLOR; + } + return CARET_COLOR; +} diff --git a/react-components/src/advanced-tree-view/view/components/tree-view-checkbox.tsx b/react-components/src/advanced-tree-view/view/components/tree-view-checkbox.tsx new file mode 100644 index 0000000000..c2982ae750 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/components/tree-view-checkbox.tsx @@ -0,0 +1,44 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type ReactElement } from 'react'; + +import { Checkbox } from '@cognite/cogs-core'; + +import { type TreeNodeType } from '../../model/tree-node-type'; +import { CheckboxState, type TreeNodeAction } from '../../model/types'; + +export const TreeViewCheckbox = ({ + node, + onClick +}: { + node: TreeNodeType; + onClick: TreeNodeAction; +}): ReactElement => { + if (node.checkboxState === undefined) { + return <>; + } + if (node.checkboxState === CheckboxState.Some) { + return ( + { + onClick(node); + }} + /> + ); + } + const checked = node.checkboxState === CheckboxState.All; + return ( + { + onClick(node); + }} + /> + ); +}; diff --git a/react-components/src/advanced-tree-view/view/components/tree-view-icon.tsx b/react-components/src/advanced-tree-view/view/components/tree-view-icon.tsx new file mode 100644 index 0000000000..2661885843 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/components/tree-view-icon.tsx @@ -0,0 +1,18 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type ReactElement } from 'react'; + +import { type TreeNodeType } from '../../model/tree-node-type'; +import { type GetIconFromIconNameFn } from '../advanced-tree-view-props'; + +export const TreeViewIcon = ({ + node, + getIconFromIconName +}: { + node: TreeNodeType; + getIconFromIconName: GetIconFromIconNameFn; +}): ReactElement => { + const Icon = getIconFromIconName(node.icon); + return ; +}; diff --git a/react-components/src/advanced-tree-view/view/components/tree-view-info.tsx b/react-components/src/advanced-tree-view/view/components/tree-view-info.tsx new file mode 100644 index 0000000000..9e69d3ca39 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/components/tree-view-info.tsx @@ -0,0 +1,51 @@ +/*! + * Copyright 2025 Cognite AS + */ +/* eslint-disable react/prop-types */ +import { useState, type ReactElement } from 'react'; + +import { InfoIcon } from '@cognite/cogs-icons'; + +import { type TreeNodeType } from '../../model/tree-node-type'; +import { type AdvancedTreeViewProps } from '../advanced-tree-view-props'; +import { HOVER_INFO_COLOR, INFO_COLOR } from '../constants'; + +export const TreeViewInfo = ({ + node, + props +}: { + node: TreeNodeType; + props: AdvancedTreeViewProps; +}): ReactElement => { + const Icon = InfoIcon; + const [isHover, setHover] = useState(false); + const color = getColor(isHover); + return ( + { + onClickInfo(node); + }} + onMouseEnter={() => { + setHover(true); + }} + onMouseLeave={() => { + setHover(false); + }} + /> + ); + + function onClickInfo(inputNode: TreeNodeType): void { + if (props.onClickInfo === undefined) { + return; + } + props.onClickInfo(inputNode); + } +}; + +function getColor(isHoverOver: boolean): string | undefined { + if (isHoverOver) { + return HOVER_INFO_COLOR; + } + return INFO_COLOR; +} diff --git a/react-components/src/advanced-tree-view/view/components/tree-view-label.tsx b/react-components/src/advanced-tree-view/view/components/tree-view-label.tsx new file mode 100644 index 0000000000..a791ad811d --- /dev/null +++ b/react-components/src/advanced-tree-view/view/components/tree-view-label.tsx @@ -0,0 +1,41 @@ +/*! + * Copyright 2025 Cognite AS + */ +/* eslint-disable react/prop-types */ +import { type ReactElement } from 'react'; + +import { Body, Tooltip } from '@cognite/cogs-core'; + +import { type TreeNodeType } from '../../model/tree-node-type'; +import { type AdvancedTreeViewProps } from '../advanced-tree-view-props'; +import { MAX_LABEL_LENGTH, TOOLTIP_DELAY } from '../constants'; + +export const TreeViewLabel = ({ + node, + props +}: { + node: TreeNodeType; + props: AdvancedTreeViewProps; +}): ReactElement => { + let disabledTooltip = true; + let label = node.label; + const maxLabelLength = props.maxLabelLength ?? MAX_LABEL_LENGTH; + if (label.length > maxLabelLength) { + label = label.substring(0, maxLabelLength) + '...'; + disabledTooltip = false; + } + + const strong = node.hasBoldLabel === true; + return ( + + + {label} + + + ); +}; diff --git a/react-components/src/advanced-tree-view/view/components/tree-view-load-more.tsx b/react-components/src/advanced-tree-view/view/components/tree-view-load-more.tsx new file mode 100644 index 0000000000..708395fe68 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/components/tree-view-load-more.tsx @@ -0,0 +1,43 @@ +/*! + * Copyright 2025 Cognite AS + */ +/* eslint-disable react/prop-types */ +import { type ReactElement } from 'react'; + +import { Button } from '@cognite/cogs-core'; + +import { type TreeNodeType } from '../../model/tree-node-type'; +import { type TreeNodeAction } from '../../model/types'; +import { type AdvancedTreeViewProps } from '../advanced-tree-view-props'; +import { HORIZONTAL_SPACING, INDENTATION, LOAD_MORE_LABEL } from '../constants'; + +export const TreeViewLoadMore = ({ + node, + onClick, + level, + props +}: { + node: TreeNodeType; + onClick: TreeNodeAction; + level: number; + props: AdvancedTreeViewProps; +}): ReactElement => { + const horizontalSpacing = HORIZONTAL_SPACING / 2 + 'px'; + const marginLeft = (level + 1) * INDENTATION + 'px'; + return ( + + ); +}; diff --git a/react-components/src/advanced-tree-view/view/components/tree-view-loading.tsx b/react-components/src/advanced-tree-view/view/components/tree-view-loading.tsx new file mode 100644 index 0000000000..dd90f3dc47 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/components/tree-view-loading.tsx @@ -0,0 +1,40 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { type ReactElement } from 'react'; + +import { Body } from '@cognite/cogs-core'; +import { LoaderIcon } from '@cognite/cogs-icons'; + +import { type AdvancedTreeViewProps } from '../advanced-tree-view-props'; +import { HORIZONTAL_SPACING, INDENTATION, LOADING_LABEL, VERTICAL_SPACING } from '../constants'; + +type Props = AdvancedTreeViewProps & { level?: number }; + +export const TreeViewLoading = (props: Props): ReactElement => { + const horizontalSpacing = HORIZONTAL_SPACING + 'px'; + + let marginVertical: string | undefined; + let marginLeft: string | undefined; + if (props.level !== undefined) { + marginVertical = VERTICAL_SPACING + 'px'; + marginLeft = (props.level + 1) * INDENTATION + HORIZONTAL_SPACING + 'px'; + } + + return ( +
+ + + {props.loadingLabel ?? LOADING_LABEL} + +
+ ); +}; diff --git a/react-components/src/advanced-tree-view/view/constants.ts b/react-components/src/advanced-tree-view/view/constants.ts new file mode 100644 index 0000000000..db3aafa51e --- /dev/null +++ b/react-components/src/advanced-tree-view/view/constants.ts @@ -0,0 +1,16 @@ +/*! + * Copyright 2025 Cognite AS + */ +export const TOOLTIP_DELAY = 500; +export const TEXT_COLOR = 'black'; +export const CARET_COLOR = 'gray'; +export const HOVER_CARET_COLOR = 'highlight'; +export const INFO_COLOR = 'black'; +export const HOVER_INFO_COLOR = 'highlight'; +export const CARET_SIZE = 16; +export const INDENTATION = 16; +export const HORIZONTAL_SPACING = 6; +export const VERTICAL_SPACING = 5; +export const LOADING_LABEL = 'Loading ....'; +export const LOAD_MORE_LABEL = 'Load more ....'; +export const MAX_LABEL_LENGTH = 25; diff --git a/react-components/src/advanced-tree-view/view/use-on-tree-node-update.ts b/react-components/src/advanced-tree-view/view/use-on-tree-node-update.ts new file mode 100644 index 0000000000..173ef6b4f0 --- /dev/null +++ b/react-components/src/advanced-tree-view/view/use-on-tree-node-update.ts @@ -0,0 +1,21 @@ +/*! + * Copyright 2025 Cognite AS + */ +import { useCallback, useEffect } from 'react'; + +import { type TreeNodeType } from '../model/tree-node-type'; + +export const useOnTreeNodeUpdate = (node: TreeNodeType | undefined, update: () => void): void => { + const memoizedUpdate = useCallback(update, [node]); + useEffect(() => { + if (node === undefined) { + // AAA + return; + } + memoizedUpdate(); + node.addTreeNodeListener?.(memoizedUpdate); + return () => { + node.removeTreeNodeListener?.(memoizedUpdate); + }; + }, [node, memoizedUpdate]); +}; diff --git a/react-components/src/architecture/base/domainObjects/DomainObject.ts b/react-components/src/architecture/base/domainObjects/DomainObject.ts index 9807bd4e72..048da08709 100644 --- a/react-components/src/architecture/base/domainObjects/DomainObject.ts +++ b/react-components/src/architecture/base/domainObjects/DomainObject.ts @@ -28,12 +28,13 @@ import { type Transaction } from '../undo/Transaction'; import { type IconName } from '../../base/utilities/IconName'; import { ToggleMetricUnitsCommand } from '../concreteCommands/ToggleMetricUnitsCommand'; import { ChangedDescription } from '../domainObjectsHelpers/ChangedDescription'; +import { type TreeNodeType } from '../../../advanced-tree-view'; /** * Represents an abstract base class for domain objects. * @abstract */ -export abstract class DomainObject { +export abstract class DomainObject implements TreeNodeType { // ================================================== // INSTANCE FIELDS // ================================================== From 30512bb16d4be71f17372213013552cca23453ce Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:12:59 +0100 Subject: [PATCH 02/15] Prepare for the three --- .../src/advanced-tree-view/index.ts | 10 +-- .../model/tree-node-type.ts | 2 +- .../base/domainObjects/DomainObject.ts | 85 +++++++++++++++++-- .../base/domainObjects/VisualDomainObject.ts | 9 +- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/react-components/src/advanced-tree-view/index.ts b/react-components/src/advanced-tree-view/index.ts index 055c6af776..78cb71e4bd 100644 --- a/react-components/src/advanced-tree-view/index.ts +++ b/react-components/src/advanced-tree-view/index.ts @@ -7,13 +7,9 @@ export type { GetIconFromIconNameFn } from './view/advanced-tree-view-props'; export type { TreeNodeType } from './model/tree-node-type'; export type { ILazyLoader } from './model/i-lazy-loader'; export { TreeNode } from './model/tree-node'; -export type { - IconName, - IconColor, - CheckboxState, - TreeNodeAction, - OnNodeLoadedAction -} from './model/types'; +export type { IconName, IconColor, TreeNodeAction, OnNodeLoadedAction } from './model/types'; + +export { CheckboxState } from './model/types'; // AAA export { scrollToNode, diff --git a/react-components/src/advanced-tree-view/model/tree-node-type.ts b/react-components/src/advanced-tree-view/model/tree-node-type.ts index 09de30b69e..87a9fa6979 100644 --- a/react-components/src/advanced-tree-view/model/tree-node-type.ts +++ b/react-components/src/advanced-tree-view/model/tree-node-type.ts @@ -2,7 +2,7 @@ * Copyright 2025 Cognite AS */ import { type ILazyLoader } from './i-lazy-loader'; -import { type TreeNodeAction, type CheckboxState, type IconColor, type IconName } from './types'; +import { type TreeNodeAction, type CheckboxState, type IconColor, type IconName } from './types'; // AAA export type TreeNodeType = { id: string; // Returns the unique id of the node diff --git a/react-components/src/architecture/base/domainObjects/DomainObject.ts b/react-components/src/architecture/base/domainObjects/DomainObject.ts index 1cfbfa6676..408a064eea 100644 --- a/react-components/src/architecture/base/domainObjects/DomainObject.ts +++ b/react-components/src/architecture/base/domainObjects/DomainObject.ts @@ -9,7 +9,7 @@ import { DomainObjectChange } from '../domainObjectsHelpers/DomainObjectChange'; import { Changes } from '../domainObjectsHelpers/Changes'; import { isInstanceOf, type Class } from '../domainObjectsHelpers/Class'; import { VisibleState } from '../domainObjectsHelpers/VisibleState'; -import { clear, removeAt } from '../utilities/extensions/arrayExtensions'; +import { clear, remove, removeAt } from '../utilities/extensions/arrayExtensions'; import { getNextColor } from '../utilities/colors/getNextColor'; import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; import { ColorType } from '../domainObjectsHelpers/ColorType'; @@ -31,7 +31,13 @@ import { type Transaction } from '../undo/Transaction'; import { type IconName } from '../../base/utilities/IconName'; import { ToggleMetricUnitsCommand } from '../concreteCommands/ToggleMetricUnitsCommand'; import { ChangedDescription } from '../domainObjectsHelpers/ChangedDescription'; -import { type TreeNodeType } from '../../../advanced-tree-view'; +import { + CheckboxState, + type IconColor, + type ILazyLoader, + type TreeNodeAction, + type TreeNodeType +} from '../../../advanced-tree-view'; /** * Represents an abstract base class for domain objects. @@ -88,6 +94,61 @@ export abstract class DomainObject implements TreeNodeType { DomainObject._counter++; this._uniqueId = DomainObject._counter; } + + // ================================================== + // IMPLEMENTATION of TreeNodeType interface + // ================================================== + + public get id(): string { + return this.uniqueId.toString(); + } + + public get isVisibleInTree(): boolean { + return true; // to be overridden + } + + public get isParent(): boolean { + return this.childCount > 0; + } + + public get iconColor(): IconColor { + return this.color.getHexString(); + } + + public get checkboxState(): CheckboxState | undefined { + switch (this.getVisibleState()) { + case VisibleState.All: + return CheckboxState.All; + case VisibleState.None: + return CheckboxState.None; + case VisibleState.Some: + return CheckboxState.Some; + default: + return undefined; + } + } + + public *getChildren(_loader?: ILazyLoader): Generator { + for (const child of this.children) { + yield child; + } + } + + private readonly _listeners: TreeNodeAction[] = []; + + public addTreeNodeListener(listener: TreeNodeAction): void { + this._listeners.push(listener); + } + + public removeTreeNodeListener(listener: TreeNodeAction): void { + remove(this._listeners, listener); + } + + public updateTreeNodeListeners(): void { + for (const listener of this._listeners) { + listener(this); + } + } // ================================================== // INSTANCE/VIRTUAL PROPERTIES // ================================================== @@ -118,7 +179,10 @@ export abstract class DomainObject implements TreeNodeType { return undefined; // to be overridden, should be added is added to the autogenerated label for more information } - public get label(): string | undefined { + public get label(): string { + if (this._label === undefined) { + return ''; + } return this._label; // The label for UI } @@ -298,10 +362,6 @@ export abstract class DomainObject implements TreeNodeType { // VIRTUAL METHODS: Appearance in the tree view // ================================================== - public get isVisibleInTree(): boolean { - return true; // to be overridden - } - public get canBeRemoved(): boolean { return true; // to be overridden } @@ -362,6 +422,7 @@ export abstract class DomainObject implements TreeNodeType { // Update the DomainObjectPanel if any DomainObjectPanelUpdater.notify(this, change); } + this.updateTreeNodeListeners(); } /** @@ -489,7 +550,14 @@ export abstract class DomainObject implements TreeNodeType { // VIRTUAL METHODS: Visibility // ================================================== - public getVisibleState(renderTarget: RevealRenderTarget): VisibleState { + public getVisibleState(renderTarget?: RevealRenderTarget): VisibleState { + // If renderTarget is not provided, use the renderTarget of the rootDomainObject + if (renderTarget === undefined) { + renderTarget = this.rootDomainObject?.renderTarget; + if (renderTarget === undefined) { + return VisibleState.Disabled; + } + } let numCandidates = 0; let numAll = 0; let numNone = 0; @@ -531,6 +599,7 @@ export abstract class DomainObject implements TreeNodeType { renderTarget: RevealRenderTarget | undefined = undefined, topLevel = true // When calling this from outside, this value should always be true ): boolean { + // If renderTarget is not provided, use the renderTarget of the rootDomainObject if (renderTarget === undefined) { renderTarget = this.rootDomainObject?.renderTarget; if (renderTarget === undefined) { diff --git a/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts b/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts index 843e7f3376..1192305866 100644 --- a/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts +++ b/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts @@ -29,7 +29,14 @@ export abstract class VisualDomainObject extends DomainObject { // OVERRIDES of DomainObject // ================================================== - public override getVisibleState(renderTarget: RevealRenderTarget): VisibleState { + public override getVisibleState(renderTarget?: RevealRenderTarget): VisibleState { + // If renderTarget is not provided, use the renderTarget of the rootDomainObject + if (renderTarget === undefined) { + renderTarget = this.rootDomainObject?.renderTarget; + if (renderTarget === undefined) { + return VisibleState.Disabled; + } + } if (this.getViewByTarget(renderTarget) !== undefined) { return VisibleState.All; } From 32a7f7babb06852697258f966c83c9a19ef18bf6 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Sat, 8 Feb 2025 07:25:19 +0100 Subject: [PATCH 03/15] Implement tree view --- react-components/package.json | 2 + .../storybook-cad/cad-tree-view.stories.tsx | 4 +- .../concreteCommands/ShowTreeViewCommand.ts | 51 ++++++++++++ .../base/domainObjects/DomainObject.ts | 47 +++++------ .../base/domainObjects/FolderDomainObject.ts | 8 ++ .../base/domainObjects/RootDomainObject.ts | 17 +++- .../base/renderTarget/RevealRenderTarget.ts | 1 + .../base/utilities/TranslateInput.ts | 2 +- .../Image360AnnotationCreateTool.ts | 1 + .../concrete/axis/AxisDomainObject.ts | 8 ++ .../concrete/clipping/ClipFolder.ts | 4 - .../concrete/clipping/ClipTool.ts | 1 + .../concrete/config/StoryBookConfig.ts | 2 + .../measurements/MeasurementFolder.ts | 4 - .../concrete/measurements/MeasurementTool.ts | 1 + .../PointsOfInterestDomainObject.ts | 15 +++- .../Architecture/DomainObjectPanel.tsx | 4 +- .../Architecture/Factories/DefaultIcons.tsx | 6 +- .../src/components/Architecture/ToolUI.tsx | 2 + .../src/components/Architecture/TreeView.tsx | 49 ++++++++++++ .../Architecture/TreeViewContainer.tsx | 52 +++++++++++++ .../src/components/i18n/Translator.tsx | 78 +++++++++++++++++++ react-components/yarn.lock | 19 +++++ 23 files changed, 332 insertions(+), 46 deletions(-) create mode 100644 react-components/src/architecture/base/concreteCommands/ShowTreeViewCommand.ts create mode 100644 react-components/src/components/Architecture/TreeView.tsx create mode 100644 react-components/src/components/Architecture/TreeViewContainer.tsx create mode 100644 react-components/src/components/i18n/Translator.tsx diff --git a/react-components/package.json b/react-components/package.json index 638e2a8ab9..4d8789e79e 100644 --- a/react-components/package.json +++ b/react-components/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@cognite/cogs.js": "^10.36.0", + "@cognite/signals": "^0.0.4", "@tanstack/react-query": "^5.32.0", "assert": "^2.1.0", "lodash": "^4.17.21" @@ -48,6 +49,7 @@ "@cognite/cogs.js": "^10.36.0", "@cognite/reveal": "^4.23.2", "@cognite/sdk": "^9.13.0", + "@cognite/signals": "^0.0.4", "@playwright/test": "1.49.0", "@storybook/addon-essentials": "8.4.5", "@storybook/addon-interactions": "8.4.5", diff --git a/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view.stories.tsx b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view.stories.tsx index 39298045fb..f6c123fde6 100644 --- a/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view.stories.tsx +++ b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-view.stories.tsx @@ -110,11 +110,11 @@ const StyledButton = styled(Button)` `; const Container = styled.div` - zindex: 1000; + z-index: 1000; padding: 16px; display: flex; height: 600px; - border-radius: 10px; + border-radius: 6px; box-shadow: 0px 1px 8px #4f52681a; overflow-x: auto; overflow-y: auto; diff --git a/react-components/src/architecture/base/concreteCommands/ShowTreeViewCommand.ts b/react-components/src/architecture/base/concreteCommands/ShowTreeViewCommand.ts new file mode 100644 index 0000000000..e566ffe8e1 --- /dev/null +++ b/react-components/src/architecture/base/concreteCommands/ShowTreeViewCommand.ts @@ -0,0 +1,51 @@ +/*! + * Copyright 2025 Cognite AS + */ + +import { type Signal, signal } from '@cognite/signals'; +import { type IconName } from '../utilities/IconName'; +import { type TranslationInput } from '../utilities/TranslateInput'; +import { RenderTargetCommand } from '../commands/RenderTargetCommand'; +import { PopupStyle } from '../domainObjectsHelpers/PopupStyle'; + +export class ShowTreeViewCommand extends RenderTargetCommand { + private readonly _showTree = signal(false); + + public get showTree(): Signal { + return this._showTree; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslationInput { + return { untranslated: 'Show tree view' }; + } + + public override get icon(): IconName { + return 'GraphTree'; + } + + public override get isEnabled(): boolean { + return true; + } + + public override get isToggle(): boolean { + return true; + } + + public override get isChecked(): boolean { + return this._showTree(); + } + + protected override invokeCore(): boolean { + this._showTree(!this._showTree()); + return true; + } + + public getPanelInfoStyle(): PopupStyle { + // Default lower left corner + return new PopupStyle({ top: 5, right: 5 }); + } +} diff --git a/react-components/src/architecture/base/domainObjects/DomainObject.ts b/react-components/src/architecture/base/domainObjects/DomainObject.ts index 408a064eea..659495ad6d 100644 --- a/react-components/src/architecture/base/domainObjects/DomainObject.ts +++ b/react-components/src/architecture/base/domainObjects/DomainObject.ts @@ -38,6 +38,7 @@ import { type TreeNodeAction, type TreeNodeType } from '../../../advanced-tree-view'; +import { translate } from '../../../components/i18n/Translator'; /** * Represents an abstract base class for domain objects. @@ -50,7 +51,6 @@ export abstract class DomainObject implements TreeNodeType { // Some basic states private _name: string | undefined = undefined; // This is the name that the user can set, if undefined it is autogenerated - private _label: string | undefined = undefined; // The label autogenerated from the name or the typeName, used in UI. private _color: Color | undefined = undefined; // Selection. This is used for selection in 3D viewer. Selection in a tree view is @@ -112,6 +112,9 @@ export abstract class DomainObject implements TreeNodeType { } public get iconColor(): IconColor { + if (!this.hasIconColor) { + return undefined; + } return this.color.getHexString(); } @@ -128,24 +131,28 @@ export abstract class DomainObject implements TreeNodeType { } } + public get isCheckboxEnabled(): boolean { + return this.getVisibleState() !== VisibleState.Disabled; + } + public *getChildren(_loader?: ILazyLoader): Generator { for (const child of this.children) { yield child; } } - private readonly _listeners: TreeNodeAction[] = []; + private readonly _treeNodeListeners: TreeNodeAction[] = []; public addTreeNodeListener(listener: TreeNodeAction): void { - this._listeners.push(listener); + this._treeNodeListeners.push(listener); } public removeTreeNodeListener(listener: TreeNodeAction): void { - remove(this._listeners, listener); + remove(this._treeNodeListeners, listener); } public updateTreeNodeListeners(): void { - for (const listener of this._listeners) { + for (const listener of this._treeNodeListeners) { listener(this); } } @@ -180,14 +187,7 @@ export abstract class DomainObject implements TreeNodeType { } public get label(): string { - if (this._label === undefined) { - return ''; - } - return this._label; // The label for UI - } - - public updateLabel(translate: TranslateDelegate): void { - this._label = this.getLabel(translate); + return this.getLabel(translate); } /** @@ -252,7 +252,7 @@ export abstract class DomainObject implements TreeNodeType { public get color(): Color { if (this._color === undefined) { - this._color = this.generateNewColor(); + this._color = getNextColor().clone(); } return this._color; } @@ -346,16 +346,11 @@ export abstract class DomainObject implements TreeNodeType { } public set isExpanded(value: boolean) { - this._isExpanded = value; - } - - public setExpandedInteractive(value: boolean): boolean { - if (this.isExpanded === value) { - return false; + if (this._isExpanded === value) { + return; } - this.isExpanded = value; + this._isExpanded = value; this.notify(Changes.expanded); - return true; } // ================================================== @@ -666,7 +661,7 @@ export abstract class DomainObject implements TreeNodeType { // INSTANCE METHODS: Visibility // ================================================== - public toggleVisibleInteractive(renderTarget: RevealRenderTarget): void { + public toggleVisibleInteractive(renderTarget?: RevealRenderTarget): void { const visibleState = this.getVisibleState(renderTarget); if (visibleState === VisibleState.None) { this.setVisibleInteractive(true, renderTarget); @@ -680,7 +675,7 @@ export abstract class DomainObject implements TreeNodeType { * @param renderTarget - The render target to check visibility in. * @returns `true` if the domain object is visible in the target, `false` otherwise. */ - public isVisible(renderTarget: RevealRenderTarget): boolean { + public isVisible(renderTarget?: RevealRenderTarget): boolean { const visibleState = this.getVisibleState(renderTarget); return visibleState === VisibleState.Some || visibleState === VisibleState.All; } @@ -964,10 +959,6 @@ export abstract class DomainObject implements TreeNodeType { // INSTANCE METHODS: Get auto name and color // ================================================== - private generateNewColor(): Color { - return this.canChangeColor ? getNextColor().clone() : WHITE_COLOR.clone(); - } - private getTypeName(): string { if (isTranslatedString(this.typeName)) { return this.typeName.key; diff --git a/react-components/src/architecture/base/domainObjects/FolderDomainObject.ts b/react-components/src/architecture/base/domainObjects/FolderDomainObject.ts index 0a5abc3444..238f143953 100644 --- a/react-components/src/architecture/base/domainObjects/FolderDomainObject.ts +++ b/react-components/src/architecture/base/domainObjects/FolderDomainObject.ts @@ -15,6 +15,14 @@ export class FolderDomainObject extends DomainObject { return { untranslated: 'Folder' }; } + public override get hasIconColor(): boolean { + return false; + } + + public override get hasIndexOnLabel(): boolean { + return false; + } + public override get icon(): IconName { return 'Folder'; } diff --git a/react-components/src/architecture/base/domainObjects/RootDomainObject.ts b/react-components/src/architecture/base/domainObjects/RootDomainObject.ts index 456c98c65a..08eca82bfb 100644 --- a/react-components/src/architecture/base/domainObjects/RootDomainObject.ts +++ b/react-components/src/architecture/base/domainObjects/RootDomainObject.ts @@ -8,6 +8,8 @@ import { DomainObject } from './DomainObject'; import { type CogniteClient } from '@cognite/sdk'; import { FdmSDK } from '../../../data-providers/FdmSDK'; import { type TranslationInput } from '../utilities/TranslateInput'; +import { type IconName } from '../utilities/IconName'; +import { AxisDomainObject } from '../../concrete/axis/AxisDomainObject'; export class RootDomainObject extends DomainObject { // ================================================== @@ -44,18 +46,27 @@ export class RootDomainObject extends DomainObject { this._renderTarget = renderTarget; this._sdk = sdk; this._fdmSdk = new FdmSDK(sdk); + this.addChild(new AxisDomainObject()); } // ================================================== // OVERRIDES // ================================================== - public override get hasIndexOnLabel(): boolean { + public override get typeName(): TranslationInput { + return { key: 'SCENE' }; + } + + public override get icon(): IconName { + return 'GraphTree'; + } + + public override get hasIconColor(): boolean { return false; } - public override get typeName(): TranslationInput { - return { key: 'SCENE' }; + public override get hasIndexOnLabel(): boolean { + return false; } public override clone(what?: symbol): DomainObject { diff --git a/react-components/src/architecture/base/renderTarget/RevealRenderTarget.ts b/react-components/src/architecture/base/renderTarget/RevealRenderTarget.ts index 561b74f045..4954e625ad 100644 --- a/react-components/src/architecture/base/renderTarget/RevealRenderTarget.ts +++ b/react-components/src/architecture/base/renderTarget/RevealRenderTarget.ts @@ -95,6 +95,7 @@ export class RevealRenderTarget { this._contextmenuController = new ContextMenuController(); this._instanceStylingController = new InstanceStylingController(); this._rootDomainObject = new RootDomainObject(this, sdk); + this._rootDomainObject.isExpanded = true; this.initializeLights(); this._viewer.on('cameraChange', this.cameraChangeHandler); diff --git a/react-components/src/architecture/base/utilities/TranslateInput.ts b/react-components/src/architecture/base/utilities/TranslateInput.ts index ee3f472d0e..0c484f54f5 100644 --- a/react-components/src/architecture/base/utilities/TranslateInput.ts +++ b/react-components/src/architecture/base/utilities/TranslateInput.ts @@ -18,7 +18,7 @@ export function isEmpty(input: TranslationInput): boolean { return input.untranslated === ''; } -export type TranslateDelegate = (key: TranslationInput) => string; +export type TranslateDelegate = (input: TranslationInput) => string; export function isTranslatedString(input: TranslationInput): input is TranslatedString { const key = (input as TranslatedString).key; diff --git a/react-components/src/architecture/concrete/annotation360/Image360AnnotationCreateTool.ts b/react-components/src/architecture/concrete/annotation360/Image360AnnotationCreateTool.ts index 9ec89896f8..bf3eac6453 100644 --- a/react-components/src/architecture/concrete/annotation360/Image360AnnotationCreateTool.ts +++ b/react-components/src/architecture/concrete/annotation360/Image360AnnotationCreateTool.ts @@ -137,6 +137,7 @@ export class Image360AnnotationCreateTool extends PrimitiveEditTool { return folder; } const newFolder = new Image360AnnotationFolder(); + newFolder.isExpanded = true; root.addChildInteractive(newFolder); return newFolder; } diff --git a/react-components/src/architecture/concrete/axis/AxisDomainObject.ts b/react-components/src/architecture/concrete/axis/AxisDomainObject.ts index a54ce9ccfe..0b081cf547 100644 --- a/react-components/src/architecture/concrete/axis/AxisDomainObject.ts +++ b/react-components/src/architecture/concrete/axis/AxisDomainObject.ts @@ -23,6 +23,14 @@ export class AxisDomainObject extends VisualDomainObject { return 'Axis3D'; } + public override get hasIconColor(): boolean { + return false; + } + + public override get hasIndexOnLabel(): boolean { + return false; + } + public override createRenderStyle(): RenderStyle | undefined { return new AxisRenderStyle(); } diff --git a/react-components/src/architecture/concrete/clipping/ClipFolder.ts b/react-components/src/architecture/concrete/clipping/ClipFolder.ts index 8a0bb6e824..d44630555a 100644 --- a/react-components/src/architecture/concrete/clipping/ClipFolder.ts +++ b/react-components/src/architecture/concrete/clipping/ClipFolder.ts @@ -9,8 +9,4 @@ export class ClipFolder extends FolderDomainObject { public override get typeName(): TranslationInput { return { key: 'CROP_BOXES_AND_CLIPPING_PLANES' }; } - - public override get hasIndexOnLabel(): boolean { - return false; - } } diff --git a/react-components/src/architecture/concrete/clipping/ClipTool.ts b/react-components/src/architecture/concrete/clipping/ClipTool.ts index db1210a1b0..e020ced469 100644 --- a/react-components/src/architecture/concrete/clipping/ClipTool.ts +++ b/react-components/src/architecture/concrete/clipping/ClipTool.ts @@ -92,6 +92,7 @@ export class ClipTool extends PrimitiveEditTool { return parent; } const newParent = new ClipFolder(); + newParent.isExpanded = true; this.renderTarget.rootDomainObject.addChildInteractive(newParent); return newParent; } diff --git a/react-components/src/architecture/concrete/config/StoryBookConfig.ts b/react-components/src/architecture/concrete/config/StoryBookConfig.ts index 33b4e04d42..235f6fefbe 100644 --- a/react-components/src/architecture/concrete/config/StoryBookConfig.ts +++ b/react-components/src/architecture/concrete/config/StoryBookConfig.ts @@ -33,6 +33,7 @@ import { Image360Action } from '@cognite/reveal'; import { type ExternalId } from '../../../data-providers/FdmSDK'; import { Image360AnnotationSelectTool } from '../annotation360/Image360AnnotationSelectTool'; import { Image360AnnotationCreateTool } from '../annotation360/Image360AnnotationCreateTool'; +import { ShowTreeViewCommand } from '../../base/concreteCommands/ShowTreeViewCommand'; export class StoryBookConfig extends BaseRevealConfig { // ================================================== @@ -59,6 +60,7 @@ export class StoryBookConfig extends BaseRevealConfig { public override createMainToolbar(): Array { return [ + new ShowTreeViewCommand(), new ToggleAllModelsVisibleCommand(), new ToggleMetricUnitsCommand(), new SettingsCommand(), diff --git a/react-components/src/architecture/concrete/measurements/MeasurementFolder.ts b/react-components/src/architecture/concrete/measurements/MeasurementFolder.ts index 93548a4bea..0a6c78f6ae 100644 --- a/react-components/src/architecture/concrete/measurements/MeasurementFolder.ts +++ b/react-components/src/architecture/concrete/measurements/MeasurementFolder.ts @@ -9,8 +9,4 @@ export class MeasurementFolder extends FolderDomainObject { public override get typeName(): TranslationInput { return { key: 'MEASUREMENTS' }; } - - public override get hasIndexOnLabel(): boolean { - return false; - } } diff --git a/react-components/src/architecture/concrete/measurements/MeasurementTool.ts b/react-components/src/architecture/concrete/measurements/MeasurementTool.ts index 95a4e62665..885bc09804 100644 --- a/react-components/src/architecture/concrete/measurements/MeasurementTool.ts +++ b/react-components/src/architecture/concrete/measurements/MeasurementTool.ts @@ -107,6 +107,7 @@ export class MeasurementTool extends PrimitiveEditTool { return parent; } const newParent = new MeasurementFolder(); + newParent.isExpanded = true; this.renderTarget.rootDomainObject.addChildInteractive(newParent); return newParent; } diff --git a/react-components/src/architecture/concrete/pointsOfInterest/PointsOfInterestDomainObject.ts b/react-components/src/architecture/concrete/pointsOfInterest/PointsOfInterestDomainObject.ts index 55bfbaff3f..16d16d9f14 100644 --- a/react-components/src/architecture/concrete/pointsOfInterest/PointsOfInterestDomainObject.ts +++ b/react-components/src/architecture/concrete/pointsOfInterest/PointsOfInterestDomainObject.ts @@ -18,6 +18,7 @@ import { type PointsOfInterestProvider } from './PointsOfInterestProvider'; import { type DmsUniqueIdentifier } from '../../../data-providers'; import { createInstanceStyleGroup } from '../../../components/Reveal3DResources/instanceStyleTranslation'; import { DefaultNodeAppearance } from '@cognite/reveal'; +import { type IconName } from '../../base/utilities/IconName'; const SELECTED_ASSOCIATED_POI_INSTANCE_STYLING_SYMBOL = Symbol( 'poi3d-selected-associated-instance-styling' @@ -41,7 +42,19 @@ export class PointsOfInterestDomainObject extends VisualDomainObject } public override get typeName(): TranslationInput { - return { untranslated: PointsOfInterestDomainObject.name }; + return { key: 'POINT_OF_INTEREST_PLURAL' }; + } + + public override get icon(): IconName { + return 'Waypoint'; + } + + public override get hasIconColor(): boolean { + return false; + } + + public override get hasIndexOnLabel(): boolean { + return false; } protected override createThreeView(): diff --git a/react-components/src/components/Architecture/DomainObjectPanel.tsx b/react-components/src/components/Architecture/DomainObjectPanel.tsx index a44ba7072d..f4b0089608 100644 --- a/react-components/src/components/Architecture/DomainObjectPanel.tsx +++ b/react-components/src/components/Architecture/DomainObjectPanel.tsx @@ -145,10 +145,10 @@ const PaddedTh = styled.th` `; const Container = withSuppressRevealEvents(styled.div` - zindex: 1000px; + z-index: 1000; position: absolute; display: block; - border-radius: 10px; + border-radius: 6px; flex-direction: column; overflow: hidden; background-color: white; diff --git a/react-components/src/components/Architecture/Factories/DefaultIcons.tsx b/react-components/src/components/Architecture/Factories/DefaultIcons.tsx index 7198331ce5..48bfb9e971 100644 --- a/react-components/src/components/Architecture/Factories/DefaultIcons.tsx +++ b/react-components/src/components/Architecture/Factories/DefaultIcons.tsx @@ -55,7 +55,9 @@ import { VectorLineIcon, VectorZigzagIcon, View360Icon, - WaypointIcon + WaypointIcon, + GraphTreeIcon, + FolderFilledIcon } from '@cognite/cogs.js'; import { type IconName } from '../../../architecture/base/utilities/IconName'; @@ -91,7 +93,9 @@ export const DefaultIcons: Array<[IconName, IconType]> = [ ['FlipHorizontal', FlipHorizontalIcon], ['FlipVertical', FlipVerticalIcon], ['Folder', FolderIcon], + ['FolderFilled', FolderFilledIcon], ['Grab', GrabIcon], + ['GraphTree', GraphTreeIcon], ['Info', InfoIcon], ['Leaf', LeafIcon], ['Location', LocationIcon], diff --git a/react-components/src/components/Architecture/ToolUI.tsx b/react-components/src/components/Architecture/ToolUI.tsx index bdc843caee..b9f7860974 100644 --- a/react-components/src/components/Architecture/ToolUI.tsx +++ b/react-components/src/components/Architecture/ToolUI.tsx @@ -5,6 +5,7 @@ import { type ReactElement } from 'react'; import { ActiveToolToolbar } from './Toolbar'; import { DomainObjectPanel } from './DomainObjectPanel'; import { AnchoredDialog } from './AnchoredDialog'; +import { TreeViewContainer } from './TreeViewContainer'; export const ToolUI = (): ReactElement => { return ( @@ -12,6 +13,7 @@ export const ToolUI = (): ReactElement => { + ); }; diff --git a/react-components/src/components/Architecture/TreeView.tsx b/react-components/src/components/Architecture/TreeView.tsx new file mode 100644 index 0000000000..44e9d4bc22 --- /dev/null +++ b/react-components/src/components/Architecture/TreeView.tsx @@ -0,0 +1,49 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type ReactElement, type FC } from 'react'; +import { type IconProps } from '@cognite/cogs.js'; +import { useRenderTarget } from '../RevealCanvas/ViewerContext'; +import { type IconName } from '../../architecture/base/utilities/IconName'; +import { IconFactory } from './Factories/IconFactory'; + +import { AdvancedTreeView, type TreeNodeType } from '../../advanced-tree-view'; +import { DomainObject } from '../../architecture'; + +export const TreeView = (): ReactElement => { + const renderTarget = useRenderTarget(); + return ( + + ); +}; + +function getIconFromIconName(icon: IconName): FC { + return IconFactory.getIcon(icon); +} + +function onSelectDomainObject(node: TreeNodeType): void { + if (!(node instanceof DomainObject)) { + return; + } + // Deselect all others + const root = node.root; + for (const descendant of root.getThisAndDescendants()) { + if (descendant !== node) { + descendant.setSelectedInteractive(false); + } + } + node.setSelectedInteractive(!node.isSelected); +} + +function onToggleDomainObject(node: TreeNodeType): void { + if (!(node instanceof DomainObject)) { + return; + } + node.toggleVisibleInteractive(); +} diff --git a/react-components/src/components/Architecture/TreeViewContainer.tsx b/react-components/src/components/Architecture/TreeViewContainer.tsx new file mode 100644 index 0000000000..7918daf75d --- /dev/null +++ b/react-components/src/components/Architecture/TreeViewContainer.tsx @@ -0,0 +1,52 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type ReactElement } from 'react'; +import { useRenderTarget } from '../RevealCanvas/ViewerContext'; +import styled from 'styled-components'; + +import { ShowTreeViewCommand } from '../../architecture/base/concreteCommands/ShowTreeViewCommand'; +import { useSignalValue } from '@cognite/signals/react'; +import { TreeView } from './TreeView'; + +export const TreeViewContainer = (): ReactElement => { + const renderTarget = useRenderTarget(); + const showTreeViewCommand = + renderTarget.commandsController.getCommandByTypeRecursive(ShowTreeViewCommand); + + if (showTreeViewCommand === undefined) { + return <>; + } + const showTree = useSignalValue(showTreeViewCommand.showTree); + if (!showTree) { + return <>; + } + const style = showTreeViewCommand.getPanelInfoStyle(); + + return ( + + + + ); +}; + +const Container = styled.div` + z-index: 1000; + position: absolute; + display: block; + border-radius: 6px; + overflow-x: auto; + overflow-y: auto; + background-color: white; + box-shadow: 0px 1px 8px #4f52681a; +`; diff --git a/react-components/src/components/i18n/Translator.tsx b/react-components/src/components/i18n/Translator.tsx new file mode 100644 index 0000000000..6cc0ec813b --- /dev/null +++ b/react-components/src/components/i18n/Translator.tsx @@ -0,0 +1,78 @@ +/*! + * Copyright 2023 Cognite AS + */ + +import { type Translations } from './types'; +import { getLanguage } from './utils'; +import { type TranslationInput } from '../../architecture'; +import { + isTranslatedString, + type TranslationKey +} from '../../architecture/base/utilities/TranslateInput'; + +import english from '../../common/i18n/en/reveal-react-components.json'; + +export function translate(input: TranslationInput): string { + return Translator.instance.translate(input); +} + +export class Translator { + private static _instance?: Translator; + private _language: string; + private _translation?: Translations; + + private constructor() { + this._language = getLanguage() ?? 'en'; + this.loadTranslationFile(); + window.addEventListener('languagechange', this.onLanguageChange); + // Since this is a singleton, we don't need to removeEventListener this listener + } + + public static get instance(): Translator { + if (Translator._instance === undefined) { + Translator._instance = new Translator(); + } + return Translator._instance; + } + + public translateByKey(key: TranslationKey): string { + if (this._translation !== undefined) { + if (this._translation[key] !== undefined) { + return this._translation[key]; + } + } + return english[key]; + } + + public translate(input: TranslationInput): string { + if (isTranslatedString(input)) { + return this.translateByKey(input.key); + } + return input.untranslated; + } + + onLanguageChange = (): void => { + const newLanguage = getLanguage(); + if (newLanguage !== undefined && newLanguage !== this._language) { + this._language = newLanguage; + this.loadTranslationFile(); + } + }; + + private loadTranslationFile(): void { + this._translation = undefined; + const load = async (): Promise => { + try { + const translationModule = (await import( + `../../common/i18n/${this._language}/reveal-react-components.json` + )) as { default: Translations }; + this._translation = translationModule.default; + } catch (error) { + console.warn('Error loading translation file. Default language: English is loaded'); + } + }; + load().catch(() => { + console.warn('Translation not found. Default language: English is loaded'); + }); + } +} diff --git a/react-components/yarn.lock b/react-components/yarn.lock index 324353ca9d..a4d11a214a 100644 --- a/react-components/yarn.lock +++ b/react-components/yarn.lock @@ -606,6 +606,7 @@ __metadata: "@cognite/cogs.js": "npm:^10.36.0" "@cognite/reveal": "npm:^4.23.2" "@cognite/sdk": "npm:^9.13.0" + "@cognite/signals": "npm:^0.0.4" "@playwright/test": "npm:1.49.0" "@storybook/addon-essentials": "npm:8.4.5" "@storybook/addon-interactions": "npm:8.4.5" @@ -729,6 +730,17 @@ __metadata: languageName: node linkType: hard +"@cognite/signals@npm:^0.0.4": + version: 0.0.4 + resolution: "@cognite/signals@npm:0.0.4" + dependencies: + "@preact/signals-core": "npm:^1.8.0" + peerDependencies: + react: 18.3.1 + checksum: 10/3e88bbcd5801e717bc118a4b6380b7230d130d035f25946f5a6e72c8cdcae6402d75e160b40290a2cbd99cbda00f2c84ee57f8f33d06dbe0eea4a85f1435df8f + languageName: node + linkType: hard + "@ctrl/tinycolor@npm:^3.4.0": version: 3.6.1 resolution: "@ctrl/tinycolor@npm:3.6.1" @@ -2131,6 +2143,13 @@ __metadata: languageName: node linkType: hard +"@preact/signals-core@npm:^1.8.0": + version: 1.8.0 + resolution: "@preact/signals-core@npm:1.8.0" + checksum: 10/480c1aaf1bce6f8bd5544eec9fd92a70ccdfffa24c23d99aa8e3c13783cc6b06ec0a3d90578c5fd368d06121cbe0f8fbe81368aa45ddba11d8a28af15410a9dc + languageName: node + linkType: hard + "@rajesh896/broprint.js@npm:^2.1.1": version: 2.1.1 resolution: "@rajesh896/broprint.js@npm:2.1.1" From c2a7508be3decf7907df068226136f29d8f37788 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Mon, 10 Feb 2025 13:10:58 +0100 Subject: [PATCH 04/15] Make info box more flexible --- .../src/advanced-tree-view/model/tree-node-type.ts | 1 + .../src/advanced-tree-view/model/tree-node.ts | 14 ++++++++++++++ .../view/advanced-tree-view-node.tsx | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/react-components/src/advanced-tree-view/model/tree-node-type.ts b/react-components/src/advanced-tree-view/model/tree-node-type.ts index 87a9fa6979..1b689f4c73 100644 --- a/react-components/src/advanced-tree-view/model/tree-node-type.ts +++ b/react-components/src/advanced-tree-view/model/tree-node-type.ts @@ -16,6 +16,7 @@ export type TreeNodeType = { // Optional Appearance isParent: boolean; // Returns true if this node has children (loaded or not loaded) hasBoldLabel?: boolean; // Returns true if the label should be rendered in bold font + hasInfoIcon?: boolean; // Returns true if the info icon should be rendered // AAA icon?: IconName; // Returns the icon, undefined is no icon iconColor?: IconColor; // undefined means default color, normally black isCheckboxEnabled?: boolean; // True if checkbox is enabled diff --git a/react-components/src/advanced-tree-view/model/tree-node.ts b/react-components/src/advanced-tree-view/model/tree-node.ts index 8a0985d66f..50abed3b9e 100644 --- a/react-components/src/advanced-tree-view/model/tree-node.ts +++ b/react-components/src/advanced-tree-view/model/tree-node.ts @@ -27,6 +27,7 @@ export class TreeNode implements TreeNodeType { private _isExpanded: boolean = false; private _isCheckboxEnabled: boolean = true; private _hasBoldLabel: boolean = false; + private _hasInfoIcon: boolean = false; // AAA private _isLoadingChildren: boolean = false; private _isLoadingSiblings: boolean = false; private _needLoadChildren = false; @@ -90,6 +91,19 @@ export class TreeNode implements TreeNodeType { } } + public get hasInfoIcon(): boolean { + // AAA + return this._hasInfoIcon; + } + + public set hasInfoIcon(value: boolean) { + // AAA + if (this._hasInfoIcon !== value) { + this._hasInfoIcon = value; + this.update(); + } + } + public get isSelected(): boolean { return this._isSelected; } diff --git a/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx b/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx index 70622e4e34..e80e154ed6 100644 --- a/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx +++ b/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx @@ -52,7 +52,7 @@ export const AdvancedTreeViewNode = ({ const backgroundColor = getBackgroundColor(inputNode, isHover); const hasHover = props.hasHover ?? true; const hasCheckboxes = props.hasCheckboxes ?? true; - const hasInfo = props.onClickInfo !== undefined; // AAA + const hasInfo = props.onClickInfo !== undefined && inputNode.hasInfoIcon !== false; // AAA const isLoadingChildren = inputNode.isLoadingChildren === true; // AAA const hasLoadMore = inputNode.isLoadingSiblings === false && From 94d2ce57fb52d355556cfa996bc4443429d1c35f Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Mon, 10 Feb 2025 14:03:14 +0100 Subject: [PATCH 05/15] Remove AAA comments --- react-components/src/advanced-tree-view/index.ts | 2 +- .../src/advanced-tree-view/model/tree-node-type.ts | 4 ++-- .../src/advanced-tree-view/model/tree-node.ts | 4 +--- .../storybook-cad/cad-tree-node.ts | 1 - .../storybook-cad/cognite-client-mock.ts | 1 - .../storybook/create-simple-mock.ts | 4 ---- .../view/advanced-tree-view-node.tsx | 4 ++-- .../view/advanced-tree-view-props.ts | 2 +- .../view/use-on-tree-node-update.ts | 1 - .../architecture/base/domainObjects/DomainObject.ts | 4 ++++ .../concrete/clipping/CropBoxDomainObject.ts | 12 ++++++++++++ 11 files changed, 23 insertions(+), 16 deletions(-) diff --git a/react-components/src/advanced-tree-view/index.ts b/react-components/src/advanced-tree-view/index.ts index 78cb71e4bd..a3389b0922 100644 --- a/react-components/src/advanced-tree-view/index.ts +++ b/react-components/src/advanced-tree-view/index.ts @@ -9,7 +9,7 @@ export type { ILazyLoader } from './model/i-lazy-loader'; export { TreeNode } from './model/tree-node'; export type { IconName, IconColor, TreeNodeAction, OnNodeLoadedAction } from './model/types'; -export { CheckboxState } from './model/types'; // AAA +export { CheckboxState } from './model/types'; export { scrollToNode, diff --git a/react-components/src/advanced-tree-view/model/tree-node-type.ts b/react-components/src/advanced-tree-view/model/tree-node-type.ts index 1b689f4c73..8f71d5c412 100644 --- a/react-components/src/advanced-tree-view/model/tree-node-type.ts +++ b/react-components/src/advanced-tree-view/model/tree-node-type.ts @@ -2,7 +2,7 @@ * Copyright 2025 Cognite AS */ import { type ILazyLoader } from './i-lazy-loader'; -import { type TreeNodeAction, type CheckboxState, type IconColor, type IconName } from './types'; // AAA +import { type TreeNodeAction, type CheckboxState, type IconColor, type IconName } from './types'; export type TreeNodeType = { id: string; // Returns the unique id of the node @@ -16,7 +16,7 @@ export type TreeNodeType = { // Optional Appearance isParent: boolean; // Returns true if this node has children (loaded or not loaded) hasBoldLabel?: boolean; // Returns true if the label should be rendered in bold font - hasInfoIcon?: boolean; // Returns true if the info icon should be rendered // AAA + hasInfoIcon?: boolean; // Returns true if the info icon should be rendered icon?: IconName; // Returns the icon, undefined is no icon iconColor?: IconColor; // undefined means default color, normally black isCheckboxEnabled?: boolean; // True if checkbox is enabled diff --git a/react-components/src/advanced-tree-view/model/tree-node.ts b/react-components/src/advanced-tree-view/model/tree-node.ts index 50abed3b9e..8857a0e333 100644 --- a/react-components/src/advanced-tree-view/model/tree-node.ts +++ b/react-components/src/advanced-tree-view/model/tree-node.ts @@ -27,7 +27,7 @@ export class TreeNode implements TreeNodeType { private _isExpanded: boolean = false; private _isCheckboxEnabled: boolean = true; private _hasBoldLabel: boolean = false; - private _hasInfoIcon: boolean = false; // AAA + private _hasInfoIcon: boolean = false; private _isLoadingChildren: boolean = false; private _isLoadingSiblings: boolean = false; private _needLoadChildren = false; @@ -92,12 +92,10 @@ export class TreeNode implements TreeNodeType { } public get hasInfoIcon(): boolean { - // AAA return this._hasInfoIcon; } public set hasInfoIcon(value: boolean) { - // AAA if (this._hasInfoIcon !== value) { this._hasInfoIcon = value; this.update(); diff --git a/react-components/src/advanced-tree-view/storybook-cad/cad-tree-node.ts b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-node.ts index 41fe26a5d0..0e03b54074 100644 --- a/react-components/src/advanced-tree-view/storybook-cad/cad-tree-node.ts +++ b/react-components/src/advanced-tree-view/storybook-cad/cad-tree-node.ts @@ -31,7 +31,6 @@ export class CadTreeNode extends TreeNode { // ================================================== public override get id(): string { - // AAA return CadTreeNode.treeIndexToString(this.treeIndex); } diff --git a/react-components/src/advanced-tree-view/storybook-cad/cognite-client-mock.ts b/react-components/src/advanced-tree-view/storybook-cad/cognite-client-mock.ts index 18c990685e..fd7c78f027 100644 --- a/react-components/src/advanced-tree-view/storybook-cad/cognite-client-mock.ts +++ b/react-components/src/advanced-tree-view/storybook-cad/cognite-client-mock.ts @@ -65,7 +65,6 @@ export class CogniteClientMock implements ICogniteClient { } public getRandomNodeId(): number { - // AAA const nodes = new Array(); for (const descendant of TREE.getThisAndDescendants()) { if (descendant instanceof CadTreeNode) { diff --git a/react-components/src/advanced-tree-view/storybook/create-simple-mock.ts b/react-components/src/advanced-tree-view/storybook/create-simple-mock.ts index a32f8ff56b..ce74dc1734 100644 --- a/react-components/src/advanced-tree-view/storybook/create-simple-mock.ts +++ b/react-components/src/advanced-tree-view/storybook/create-simple-mock.ts @@ -27,19 +27,15 @@ export function createSimpleMock(args: SimpleMockArgs): TreeNode { for (const node of root.getThisAndDescendants()) { if (args.hasColors === true) { - // AAA node.iconColor = getRandomColor(); } if (args.hasBoldLabels === true) { - // AAA node.hasBoldLabel = Math.random() < 0.5; } if (args.hasDisabledCheckboxes === true) { - // AAA node.isCheckboxEnabled = Math.random() < 0.5; } if (args.hasCheckboxes === true) { - // AAA node.checkboxState = CheckboxState.None; } node.icon = node.isParent ? 'Folder' : 'Snow'; diff --git a/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx b/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx index e80e154ed6..0bcdc53257 100644 --- a/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx +++ b/react-components/src/advanced-tree-view/view/advanced-tree-view-node.tsx @@ -52,8 +52,8 @@ export const AdvancedTreeViewNode = ({ const backgroundColor = getBackgroundColor(inputNode, isHover); const hasHover = props.hasHover ?? true; const hasCheckboxes = props.hasCheckboxes ?? true; - const hasInfo = props.onClickInfo !== undefined && inputNode.hasInfoIcon !== false; // AAA - const isLoadingChildren = inputNode.isLoadingChildren === true; // AAA + const hasInfo = props.onClickInfo !== undefined && inputNode.hasInfoIcon !== false; + const isLoadingChildren = inputNode.isLoadingChildren === true; const hasLoadMore = inputNode.isLoadingSiblings === false && inputNode.needLoadSiblings === true && diff --git a/react-components/src/advanced-tree-view/view/advanced-tree-view-props.ts b/react-components/src/advanced-tree-view/view/advanced-tree-view-props.ts index 0741115f76..f89dcdd820 100644 --- a/react-components/src/advanced-tree-view/view/advanced-tree-view-props.ts +++ b/react-components/src/advanced-tree-view/view/advanced-tree-view-props.ts @@ -39,4 +39,4 @@ export type AdvancedTreeViewProps = { * so the data model can be independent on JXS. */ -export type GetIconFromIconNameFn = (icon: IconName) => FC; // AAA +export type GetIconFromIconNameFn = (icon: IconName) => FC; diff --git a/react-components/src/advanced-tree-view/view/use-on-tree-node-update.ts b/react-components/src/advanced-tree-view/view/use-on-tree-node-update.ts index 173ef6b4f0..9aadd54a53 100644 --- a/react-components/src/advanced-tree-view/view/use-on-tree-node-update.ts +++ b/react-components/src/advanced-tree-view/view/use-on-tree-node-update.ts @@ -9,7 +9,6 @@ export const useOnTreeNodeUpdate = (node: TreeNodeType | undefined, update: () = const memoizedUpdate = useCallback(update, [node]); useEffect(() => { if (node === undefined) { - // AAA return; } memoizedUpdate(); diff --git a/react-components/src/architecture/base/domainObjects/DomainObject.ts b/react-components/src/architecture/base/domainObjects/DomainObject.ts index 659495ad6d..19f03a4798 100644 --- a/react-components/src/architecture/base/domainObjects/DomainObject.ts +++ b/react-components/src/architecture/base/domainObjects/DomainObject.ts @@ -118,6 +118,10 @@ export abstract class DomainObject implements TreeNodeType { return this.color.getHexString(); } + public get hasBoldLabel(): boolean { + return false; + } + public get checkboxState(): CheckboxState | undefined { switch (this.getVisibleState()) { case VisibleState.All: diff --git a/react-components/src/architecture/concrete/clipping/CropBoxDomainObject.ts b/react-components/src/architecture/concrete/clipping/CropBoxDomainObject.ts index 401e135771..089c0f451b 100644 --- a/react-components/src/architecture/concrete/clipping/CropBoxDomainObject.ts +++ b/react-components/src/architecture/concrete/clipping/CropBoxDomainObject.ts @@ -66,6 +66,18 @@ export class CropBoxDomainObject extends BoxDomainObject { return false; } + public override get hasBoldLabel(): boolean { + const root = this.rootDomainObject; + if (root === undefined) { + return false; + } + const renderTarget = root.renderTarget; + if (!renderTarget.isGlobalClippingActive) { + return false; + } + return renderTarget.isGlobalCropBox(this); + } + public override clone(what?: symbol): DomainObject { const clone = new CropBoxDomainObject(); clone.copyFrom(this, what); From 0c11d8c22b734b2cda011dd9313a0a91eab48e47 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Tue, 18 Feb 2025 10:15:30 +0100 Subject: [PATCH 06/15] Fix build error after update --- .../architecture/base/domainObjects/VisualDomainObject.ts | 2 +- .../architecture/concrete/clipping/CropBoxDomainObject.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts b/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts index d661a08a0f..eab063e557 100644 --- a/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts +++ b/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts @@ -33,7 +33,7 @@ export abstract class VisualDomainObject extends DomainObject { public override getVisibleState(renderTarget?: RevealRenderTarget): VisibleState { // If renderTarget is not provided, use the renderTarget of the rootDomainObject if (renderTarget === undefined) { - renderTarget = this.rootDomainObject?.renderTarget; + renderTarget = getRenderTarget(this); if (renderTarget === undefined) { return VisibleState.Disabled; } diff --git a/react-components/src/architecture/concrete/clipping/CropBoxDomainObject.ts b/react-components/src/architecture/concrete/clipping/CropBoxDomainObject.ts index dee01fe65f..6e7a19b84c 100644 --- a/react-components/src/architecture/concrete/clipping/CropBoxDomainObject.ts +++ b/react-components/src/architecture/concrete/clipping/CropBoxDomainObject.ts @@ -14,7 +14,7 @@ import { FocusType } from '../../base/domainObjectsHelpers/FocusType'; import { type DomainObject } from '../../base/domainObjects/DomainObject'; import { type IconName } from '../../base/utilities/IconName'; import { SolidPrimitiveRenderStyle } from '../primitives/common/SolidPrimitiveRenderStyle'; -import { getRoot } from '../../base/domainObjects/getRoot'; +import { getRenderTarget, getRoot } from '../../base/domainObjects/getRoot'; export class CropBoxDomainObject extends BoxDomainObject { // ================================================== @@ -68,11 +68,10 @@ export class CropBoxDomainObject extends BoxDomainObject { } public override get hasBoldLabel(): boolean { - const root = this.rootDomainObject; - if (root === undefined) { + const renderTarget = getRenderTarget(this); + if (renderTarget === undefined) { return false; } - const renderTarget = root.renderTarget; if (!renderTarget.isGlobalClippingActive) { return false; } From 75d003226b36cc286f79f89ac071bced595d3576 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Tue, 18 Feb 2025 22:36:19 +0100 Subject: [PATCH 07/15] Fine tuning --- .../Image360CollectionDomainObject.ts | 4 ++++ .../concrete/reveal/RevealModelsUtils.ts | 12 ++++++++++++ .../concrete/reveal/cad/CadDomainObject.ts | 4 ++++ .../reveal/pointCloud/PointCloudDomainObject.ts | 4 ++++ 4 files changed, 24 insertions(+) diff --git a/react-components/src/architecture/concrete/reveal/Image360Collection/Image360CollectionDomainObject.ts b/react-components/src/architecture/concrete/reveal/Image360Collection/Image360CollectionDomainObject.ts index b6e24c7239..ee0054fec2 100644 --- a/react-components/src/architecture/concrete/reveal/Image360Collection/Image360CollectionDomainObject.ts +++ b/react-components/src/architecture/concrete/reveal/Image360Collection/Image360CollectionDomainObject.ts @@ -48,6 +48,10 @@ export class Image360CollectionDomainObject extends VisualDomainObject { return 'View360'; } + public override get hasIconColor(): boolean { + return false; + } + public override createRenderStyle(): RenderStyle | undefined { return new Image360CollectionRenderStyle(); } diff --git a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.ts b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.ts index 0b3a4e5a9c..fa46de4051 100644 --- a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.ts +++ b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.ts @@ -56,6 +56,9 @@ export class RevealModelsUtils { return await renderTarget.viewer.addCadModel(options).then((model) => { const domainObject = new CadDomainObject(model); root.addChildInteractive(domainObject); + if (model.visible) { + domainObject.setVisibleInteractive(true); + } return model; }); } @@ -71,6 +74,9 @@ export class RevealModelsUtils { return await renderTarget.viewer.addPointCloudModel(options).then((model) => { const domainObject = new PointCloudDomainObject(model); root.addChildInteractive(domainObject); + if (model.visible) { + domainObject.setVisibleInteractive(true); + } return model; }); } @@ -90,6 +96,9 @@ export class RevealModelsUtils { .then((model) => { const domainObject = new Image360CollectionDomainObject(model); root.addChildInteractive(domainObject); + if (model.getIconsVisibility()) { + domainObject.setVisibleInteractive(true); + } return model; }); } else { @@ -102,6 +111,9 @@ export class RevealModelsUtils { .then((model) => { const domainObject = new Image360CollectionDomainObject(model); root.addChildInteractive(domainObject); + if (model.getIconsVisibility()) { + domainObject.setVisibleInteractive(true); + } return model; }); } diff --git a/react-components/src/architecture/concrete/reveal/cad/CadDomainObject.ts b/react-components/src/architecture/concrete/reveal/cad/CadDomainObject.ts index 2232f42084..286e71a657 100644 --- a/react-components/src/architecture/concrete/reveal/cad/CadDomainObject.ts +++ b/react-components/src/architecture/concrete/reveal/cad/CadDomainObject.ts @@ -48,6 +48,10 @@ export class CadDomainObject extends VisualDomainObject { return 'Cubes'; } + public override get hasIconColor(): boolean { + return false; + } + public override createRenderStyle(): RenderStyle | undefined { return new CadRenderStyle(); } diff --git a/react-components/src/architecture/concrete/reveal/pointCloud/PointCloudDomainObject.ts b/react-components/src/architecture/concrete/reveal/pointCloud/PointCloudDomainObject.ts index 6c4753cab6..e4eebdd0ac 100644 --- a/react-components/src/architecture/concrete/reveal/pointCloud/PointCloudDomainObject.ts +++ b/react-components/src/architecture/concrete/reveal/pointCloud/PointCloudDomainObject.ts @@ -47,6 +47,10 @@ export class PointCloudDomainObject extends VisualDomainObject { return 'PointCloud'; } + public override get hasIconColor(): boolean { + return false; + } + public override createRenderStyle(): RenderStyle | undefined { return new PointCloudRenderStyle(); } From 7bd8dc40f33bc0e995b0428a8a2a57a55695d519 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Fri, 21 Feb 2025 10:21:02 +0100 Subject: [PATCH 08/15] Remove lint --- react-components/src/advanced-tree-view/model/tree-node.ts | 1 - .../src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/react-components/src/advanced-tree-view/model/tree-node.ts b/react-components/src/advanced-tree-view/model/tree-node.ts index 8857a0e333..bd3b4c8f88 100644 --- a/react-components/src/advanced-tree-view/model/tree-node.ts +++ b/react-components/src/advanced-tree-view/model/tree-node.ts @@ -217,7 +217,6 @@ export class TreeNode implements TreeNodeType { // INSTANCE METHODS: Getters // ================================================== - // eslint-disable-next-line @typescript-eslint/prefer-return-this-type public getRoot(): TreeNode { if (this.parent !== undefined) { return this.parent.getRoot(); diff --git a/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts b/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts index 53a69f8576..907d1b6f96 100644 --- a/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts +++ b/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts @@ -155,7 +155,6 @@ export class CadNodesLoader implements ILazyLoader { if (cdfRoot.id !== root.nodeId) { throw new Error('The root node is not the same as the current node'); } - // eslint-disable-next-line @typescript-eslint/no-this-alias let parent = root; for (let i = 1; i < newCdfNodes.length; i++) { const cdfNode = newCdfNodes[i]; From b7c67227708d3b9f39704bba26c41e59b8175d24 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Fri, 21 Feb 2025 10:40:07 +0100 Subject: [PATCH 09/15] Create NodeTreeView.tsx --- .../components/Architecture/NodeTreeView.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 react-components/src/components/Architecture/NodeTreeView.tsx diff --git a/react-components/src/components/Architecture/NodeTreeView.tsx b/react-components/src/components/Architecture/NodeTreeView.tsx new file mode 100644 index 0000000000..9c4c5a0144 --- /dev/null +++ b/react-components/src/components/Architecture/NodeTreeView.tsx @@ -0,0 +1,28 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type ReactElement } from 'react'; + +import { + AdvancedTreeView, + onRecursiveToggleNode, + onSingleSelectNode, + type TreeNodeType +} from '../../advanced-tree-view'; +import { type CadNodesLoader } from '../../advanced-tree-view/storybook-cad/cad-nodes-loader'; + +export const NodeTreeView = (loader: CadNodesLoader): ReactElement => { + return ( + + ); +}; + +function onClickInfo(node: TreeNodeType): void { + alert('You clicked: ' + node.label); +} From b768749e1372d4bbe412aca1316a69214b1d1b0c Mon Sep 17 00:00:00 2001 From: Pramod S Date: Fri, 21 Feb 2025 10:52:03 +0100 Subject: [PATCH 10/15] updated export --- react-components/src/advanced-tree-view/index.ts | 3 +-- .../src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/react-components/src/advanced-tree-view/index.ts b/react-components/src/advanced-tree-view/index.ts index a3389b0922..0c79412c92 100644 --- a/react-components/src/advanced-tree-view/index.ts +++ b/react-components/src/advanced-tree-view/index.ts @@ -2,8 +2,7 @@ * Copyright 2025 Cognite AS */ export { AdvancedTreeView } from './view/advanced-tree-view'; -export type { AdvancedTreeViewProps } from './view/advanced-tree-view-props'; -export type { GetIconFromIconNameFn } from './view/advanced-tree-view-props'; +export type { AdvancedTreeViewProps, GetIconFromIconNameFn } from './view/advanced-tree-view-props'; export type { TreeNodeType } from './model/tree-node-type'; export type { ILazyLoader } from './model/i-lazy-loader'; export { TreeNode } from './model/tree-node'; diff --git a/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts b/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts index 907d1b6f96..07134aecc7 100644 --- a/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts +++ b/react-components/src/advanced-tree-view/storybook-cad/cad-nodes-loader.ts @@ -1,7 +1,7 @@ /*! * Copyright 2025 Cognite AS */ -import type { Node3D } from '@cognite/sdk'; +import { type Node3D } from '@cognite/sdk'; import { type TreeNodeType, type ILazyLoader } from '..'; From adea98ad64de41d0351ad6abfdd95ba55d6feef8 Mon Sep 17 00:00:00 2001 From: Pramod S Date: Fri, 21 Feb 2025 13:51:52 +0100 Subject: [PATCH 11/15] fixed unit test and added new mock data for 360 images icon visibility --- .../concrete/reveal/RevealModelsUtils.test.ts | 10 +++++++++- react-components/tests/unit-tests/fixtures/image360.ts | 10 +++++++++- .../tests/unit-tests/fixtures/renderTarget.ts | 4 +++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts index 55446e39e1..47812b5d2f 100644 --- a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts +++ b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts @@ -5,7 +5,9 @@ import { describe, expect, test, vi, beforeEach } from 'vitest'; import { cadModelOptions, createCadMock } from '../../../../tests/unit-tests/fixtures/cadModel'; import { createImage360ClassicMock, - image360ClassicOptions + getIconsVisibiltyMock, + image360ClassicOptions, + setIconsVisibilityMock } from '../../../../tests/unit-tests/fixtures/image360'; import { createPointCloudMock, @@ -146,6 +148,12 @@ describe('RevealModelsUtils', () => { let domainObject = RevealModelsUtils.getByRevealModel(root, model); expect(domainObject).not.toBe(undefined); + setIconsVisibilityMock.mockImplementation((visible) => { + getIconsVisibiltyMock.mockReturnValue(visible); + }); + model.setIconsVisibility(false); + expect(model.getIconsVisibility()).toEqual(false); + RevealModelsUtils.remove(renderTargetMock, model); domainObject = RevealModelsUtils.getByRevealModel(root, model); expect(domainObject).toBe(undefined); diff --git a/react-components/tests/unit-tests/fixtures/image360.ts b/react-components/tests/unit-tests/fixtures/image360.ts index 085eec682d..006d845a26 100644 --- a/react-components/tests/unit-tests/fixtures/image360.ts +++ b/react-components/tests/unit-tests/fixtures/image360.ts @@ -3,7 +3,8 @@ import { type DMDataSourceType, type Image360Collection } from '@cognite/reveal'; -import { Mock } from 'moq.ts'; +import { Mock, It } from 'moq.ts'; +import { vi } from 'vitest'; import { type AddImage360CollectionOptions } from '../../../src'; export const image360ClassicOptions: AddImage360CollectionOptions = { @@ -17,10 +18,17 @@ export const image360DmOptions: AddImage360CollectionOptions = { space: 'testImage360Space' }; +export const getIconsVisibiltyMock = vi.fn<[], boolean>(); +export const setIconsVisibilityMock = vi.fn<[boolean], void>(); + export function createImage360ClassicMock(): Image360Collection { return new Mock>() .setup((p) => p.id) .returns('siteId') + .setup((p) => p.getIconsVisibility) + .returns(getIconsVisibiltyMock) + .setup((p) => p.setIconsVisibility) + .returns(setIconsVisibilityMock) .object(); } diff --git a/react-components/tests/unit-tests/fixtures/renderTarget.ts b/react-components/tests/unit-tests/fixtures/renderTarget.ts index d5e1e75743..ee3f96bea0 100644 --- a/react-components/tests/unit-tests/fixtures/renderTarget.ts +++ b/react-components/tests/unit-tests/fixtures/renderTarget.ts @@ -33,7 +33,9 @@ export function createRenderTargetMock(): RevealRenderTarget { .setup((p) => p.cdfCaches) .returns(cdfCachesMock) .setup((p) => p.commandsController) - .returns(commandsControllerMock); + .returns(commandsControllerMock) + .setup((p) => p.invalidate.bind(p)) + .returns(vi.fn()); const root = new RootDomainObject(mock.object(), sdkMock); mock.setup((p) => p.rootDomainObject).returns(root); From f6fb6e4fd080aefa3a74ae2186a863a4ee4e061d Mon Sep 17 00:00:00 2001 From: Pramod S Date: Fri, 21 Feb 2025 14:09:09 +0100 Subject: [PATCH 12/15] fixed merge conflict --- .../architecture/concrete/reveal/RevealModelsUtils.test.ts | 4 +++- react-components/tests/tests-utilities/fixtures/image360.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts index 43247b00e5..be79304842 100644 --- a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts +++ b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts @@ -8,7 +8,9 @@ import { } from '../../../../tests/tests-utilities/fixtures/cadModel'; import { createImage360ClassicMock, - image360ClassicOptions + getIconsVisibiltyMock, + image360ClassicOptions, + setIconsVisibilityMock } from '../../../../tests/tests-utilities/fixtures/image360'; import { createPointCloudMock, diff --git a/react-components/tests/tests-utilities/fixtures/image360.ts b/react-components/tests/tests-utilities/fixtures/image360.ts index 006d845a26..f0d13b0667 100644 --- a/react-components/tests/tests-utilities/fixtures/image360.ts +++ b/react-components/tests/tests-utilities/fixtures/image360.ts @@ -3,7 +3,7 @@ import { type DMDataSourceType, type Image360Collection } from '@cognite/reveal'; -import { Mock, It } from 'moq.ts'; +import { Mock } from 'moq.ts'; import { vi } from 'vitest'; import { type AddImage360CollectionOptions } from '../../../src'; From 14ce5f08d4238d3f1ced861428bbd9fc46708490 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:09:07 +0100 Subject: [PATCH 13/15] Fix test --- .../architecture/concrete/reveal/RevealModelsUtils.test.ts | 6 ------ react-components/tests/tests-utilities/fixtures/image360.ts | 4 ++++ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts index be79304842..c0525aba57 100644 --- a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts +++ b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts @@ -151,12 +151,6 @@ describe('RevealModelsUtils', () => { let domainObject = RevealModelsUtils.getByRevealModel(root, model); expect(domainObject).not.toBe(undefined); - setIconsVisibilityMock.mockImplementation((visible) => { - getIconsVisibiltyMock.mockReturnValue(visible); - }); - model.setIconsVisibility(false); - expect(model.getIconsVisibility()).toEqual(false); - RevealModelsUtils.remove(renderTargetMock, model); domainObject = RevealModelsUtils.getByRevealModel(root, model); expect(domainObject).toBe(undefined); diff --git a/react-components/tests/tests-utilities/fixtures/image360.ts b/react-components/tests/tests-utilities/fixtures/image360.ts index f0d13b0667..65bda0824a 100644 --- a/react-components/tests/tests-utilities/fixtures/image360.ts +++ b/react-components/tests/tests-utilities/fixtures/image360.ts @@ -22,6 +22,10 @@ export const getIconsVisibiltyMock = vi.fn<[], boolean>(); export const setIconsVisibilityMock = vi.fn<[boolean], void>(); export function createImage360ClassicMock(): Image360Collection { + setIconsVisibilityMock.mockImplementation((visible) => { + getIconsVisibiltyMock.mockReturnValue(visible); + }); + return new Mock>() .setup((p) => p.id) .returns('siteId') From 997e4c46342ea63b4d2417f32a1aa29bfdfd3fe0 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:12:39 +0100 Subject: [PATCH 14/15] Update RevealModelsUtils.test.ts --- .../architecture/concrete/reveal/RevealModelsUtils.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts index c0525aba57..8409c1745b 100644 --- a/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts +++ b/react-components/src/architecture/concrete/reveal/RevealModelsUtils.test.ts @@ -8,9 +8,7 @@ import { } from '../../../../tests/tests-utilities/fixtures/cadModel'; import { createImage360ClassicMock, - getIconsVisibiltyMock, - image360ClassicOptions, - setIconsVisibilityMock + image360ClassicOptions } from '../../../../tests/tests-utilities/fixtures/image360'; import { createPointCloudMock, From 9565014c0a3a4c68fddf62863a26edb9147478a0 Mon Sep 17 00:00:00 2001 From: Christopher Tannum Date: Fri, 21 Feb 2025 15:35:23 +0100 Subject: [PATCH 15/15] fix: include coverage default excludes --- react-components/vite.config.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/react-components/vite.config.ts b/react-components/vite.config.ts index 5a32f24823..035f5eafff 100644 --- a/react-components/vite.config.ts +++ b/react-components/vite.config.ts @@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { externalizeDeps } from 'vite-plugin-externalize-deps'; import { exec } from 'node:child_process'; +import { coverageConfigDefaults } from 'vitest/config'; export default defineConfig(({ command }) => { return { @@ -31,7 +32,13 @@ export default defineConfig(({ command }) => { reporters: ['default'], coverage: { reportsDirectory: '../coverage/reveal-react-components', - exclude: ['src/**/*.spec.ts', 'src/**/*.spec.tsx', 'src/**/*.test.ts', 'src/**/*.test.tsx'] + exclude: [ + ...coverageConfigDefaults.exclude, + 'src/**/*.spec.ts', + 'src/**/*.spec.tsx', + 'src/**/*.test.ts', + 'src/**/*.test.tsx' + ] }, // Need to add E5 modules as inlined dependencies to be able to import them in tests. server: {