diff --git a/change/@itwin-tree-widget-react-944faa0b-268a-40c9-9ae5-49d45b91b7c7.json b/change/@itwin-tree-widget-react-944faa0b-268a-40c9-9ae5-49d45b91b7c7.json new file mode 100644 index 0000000000..a4b34e80f6 --- /dev/null +++ b/change/@itwin-tree-widget-react-944faa0b-268a-40c9-9ae5-49d45b91b7c7.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "`CategoriesTree` component rendered `Categories` as a flat list, where each `Category` had zero or more child `SubCategories`. Some iTwin.js applications started to group `Categories` under `DefinitionContainers` and wanted to see them displayed in `CategoriesTree` component. Added `DefinitionContainers` to `CategoriesTree` component. This change doesn't affect applications that don't have `DefinitionContainers`.", + "packageName": "@itwin/tree-widget-react", + "email": "100586436+JonasDov@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts index a143c08ddd..b2bad0da02 100644 --- a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts +++ b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts @@ -107,11 +107,14 @@ test.describe("Categories tree", () => { await node.click(); const treeContainer = page.locator("#tw-tree-renderer-container"); + // ensure checkbox is not disabled + const checkbox = node.getByRole("checkbox"); + await expect(checkbox).toBeEnabled(); + // focus on checkbox using keyboard await page.keyboard.press("Tab"); // ensure checkbox is focused - const checkbox = node.getByRole("checkbox"); await expect(checkbox).toBeFocused(); await takeScreenshot(page, node, { boundingComponent: treeContainer, expandBy: { top: 10, bottom: 10 } }); diff --git a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-default-expanded-tree-node-1-chromium-linux.png b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-default-expanded-tree-node-1-chromium-linux.png index 3ef1b3a6cb..31787ea262 100644 Binary files a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-default-expanded-tree-node-1-chromium-linux.png and b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-default-expanded-tree-node-1-chromium-linux.png differ diff --git a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-default-node-with-active-filtering-1-chromium-linux.png b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-default-node-with-active-filtering-1-chromium-linux.png index 5cf3853742..7f2d5fe39f 100644 Binary files a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-default-node-with-active-filtering-1-chromium-linux.png and b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-default-node-with-active-filtering-1-chromium-linux.png differ diff --git a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-enlarged-expanded-tree-node-1-chromium-linux.png b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-enlarged-expanded-tree-node-1-chromium-linux.png index e6290ddf86..8e31bd8062 100644 Binary files a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-enlarged-expanded-tree-node-1-chromium-linux.png and b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-enlarged-expanded-tree-node-1-chromium-linux.png differ diff --git a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-enlarged-node-with-active-filtering-1-chromium-linux.png b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-enlarged-node-with-active-filtering-1-chromium-linux.png index 00ac909212..f9a635afd1 100644 Binary files a/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-enlarged-node-with-active-filtering-1-chromium-linux.png and b/packages/itwin/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-Density-enlarged-node-with-active-filtering-1-chromium-linux.png differ diff --git a/packages/itwin/tree-widget/src/test/IModelUtils.ts b/packages/itwin/tree-widget/src/test/IModelUtils.ts index 2c4e7a7e0c..4741db8bb7 100644 --- a/packages/itwin/tree-widget/src/test/IModelUtils.ts +++ b/packages/itwin/tree-widget/src/test/IModelUtils.ts @@ -265,6 +265,24 @@ export function insertSpatialCategory( return { className, id }; } +export function insertDefinitionContainer( + props: BaseInstanceInsertProps & { codeValue: string; modelId?: Id64String; isPrivate?: boolean } & Partial< + Omit + >, +) { + const { builder, classFullName, modelId, codeValue, ...elementProps } = props; + const defaultClassName = `BisCore${props.fullClassNameSeparator ?? "."}DefinitionContainer`; + const className = classFullName ?? defaultClassName; + const model = modelId ?? IModel.dictionaryId; + const id = builder.insertElement({ + classFullName: className, + model, + code: builder.createCode(model, BisCodeSpec.nullCodeSpec, codeValue), + ...elementProps, + }); + return { className, id }; +} + export function insertDrawingCategory( props: BaseInstanceInsertProps & { codeValue: string; modelId?: Id64String } & Partial>, ) { diff --git a/packages/itwin/tree-widget/src/test/trees/categories-tree/CategoriesTreeDefinition.test.ts b/packages/itwin/tree-widget/src/test/trees/categories-tree/CategoriesTreeDefinition.test.ts index a38809e772..39e636db79 100644 --- a/packages/itwin/tree-widget/src/test/trees/categories-tree/CategoriesTreeDefinition.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/categories-tree/CategoriesTreeDefinition.test.ts @@ -8,13 +8,20 @@ import { ECSchemaRpcInterface } from "@itwin/ecschema-rpcinterface-common"; import { ECSchemaRpcImpl } from "@itwin/ecschema-rpcinterface-impl"; import { PresentationRpcInterface } from "@itwin/presentation-common"; import { createIModelHierarchyProvider } from "@itwin/presentation-hierarchies"; -import { - HierarchyCacheMode, initialize as initializePresentationTesting, terminate as terminatePresentationTesting, -} from "@itwin/presentation-testing"; +import { HierarchyCacheMode, initialize as initializePresentationTesting, terminate as terminatePresentationTesting } from "@itwin/presentation-testing"; import { CategoriesTreeDefinition } from "../../../tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.js"; +import { CategoriesTreeIdsCache } from "../../../tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.js"; import { - buildIModel, insertDrawingCategory, insertDrawingGraphic, insertDrawingModelWithPartition, insertPhysicalElement, insertPhysicalModelWithPartition, - insertSpatialCategory, insertSubCategory, + buildIModel, + insertDefinitionContainer, + insertDrawingCategory, + insertDrawingGraphic, + insertDrawingModelWithPartition, + insertPhysicalElement, + insertPhysicalModelWithPartition, + insertSpatialCategory, + insertSubCategory, + insertSubModel, } from "../../IModelUtils.js"; import { createIModelAccess } from "../Common.js"; import { NodeValidators, validateHierarchy } from "../HierarchyValidation.js"; @@ -54,9 +61,234 @@ describe("Categories tree", () => { return { category, privateCategory }; }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + + await validateHierarchy({ + provider, + expect: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.category], + supportsFiltering: true, + children: false, + }), + ], + }); + }); + + it("does not show definition container when it doesn't contain category", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category }; + }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + + await validateHierarchy({ + provider, + expect: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.category], + supportsFiltering: true, + children: false, + }), + ], + }); + }); + + it("does not show definition container when it contains definition container without categories", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModel.id }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + + await validateHierarchy({ + provider, + expect: [], + }); + }); + + it("does not show definition container or category when category is private", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModel.id, isPrivate: true }); + + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + + await validateHierarchy({ + provider, + expect: [], + }); + }); + + it("does not show definition container or category when definition container is private", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer", isPrivate: true }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModel.id }); + + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + + await validateHierarchy({ + provider, + expect: [], + }); + }); + + it("does not show definition containers or categories when definition container contains another definition container that is private", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const definitionContainerChild = insertDefinitionContainer({ + builder, + codeValue: "DefinitionContainerChild", + isPrivate: true, + modelId: definitionModel.id, + }); + const defintionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: defintionModelChild.id }); + + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + + await validateHierarchy({ + provider, + expect: [], + }); + }); + + it("shows definition container when it contains category", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModel.id }); + + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainer, category }; + }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + + await validateHierarchy({ + provider, + expect: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.definitionContainer], + supportsFiltering: true, + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.category], + label: "SpatialCategory", + children: false, + }), + ], + }), + ], + }); + }); + + it("shows all definition containers when they contain category directly or indirectly", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModel.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainer, definitionContainerChild, category }; + }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + await validateHierarchy({ - provider: createCategoryTreeProvider(imodel, "3d"), + provider, expect: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.definitionContainer], + supportsFiltering: true, + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.definitionContainerChild], + label: "DefinitionContainerChild", + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.category], + label: "SpatialCategory", + children: false, + }), + ], + }), + ], + }), + ], + }); + }); + + it("shows root categories and definition container", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + const childCategory = insertSpatialCategory({ builder, codeValue: "ScChild", modelId: definitionModel.id }); + + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: childCategory.id }); + + return { category, definitionContainer, childCategory }; + }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + + await validateHierarchy({ + provider, + expect: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.definitionContainer], + supportsFiltering: true, + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.childCategory], + label: "ScChild", + children: false, + }), + ], + }), NodeValidators.createForInstanceNode({ instanceKeys: [keys.category], supportsFiltering: true, @@ -78,8 +310,11 @@ describe("Categories tree", () => { return { category, subCategory, privateSubCategory }; }); + + using provider = createCategoryTreeProvider(imodel, "3d"); + await validateHierarchy({ - provider: createCategoryTreeProvider(imodel, "3d"), + provider, expect: [ NodeValidators.createForInstanceNode({ instanceKeys: [keys.category], @@ -111,8 +346,11 @@ describe("Categories tree", () => { return { category, privateCategory }; }); + + using provider = createCategoryTreeProvider(imodel, "2d"); + await validateHierarchy({ - provider: createCategoryTreeProvider(imodel, "2d"), + provider, expect: [ NodeValidators.createForInstanceNode({ instanceKeys: [keys.category], @@ -135,8 +373,11 @@ describe("Categories tree", () => { return { category, subCategory, privateSubCategory }; }); + + using provider = createCategoryTreeProvider(imodel, "2d"); + await validateHierarchy({ - provider: createCategoryTreeProvider(imodel, "2d"), + provider, expect: [ NodeValidators.createForInstanceNode({ instanceKeys: [keys.category], @@ -162,6 +403,6 @@ function createCategoryTreeProvider(imodel: IModelConnection, viewType: "2d" | " const imodelAccess = createIModelAccess(imodel); return createIModelHierarchyProvider({ imodelAccess, - hierarchyDefinition: new CategoriesTreeDefinition({ imodelAccess, viewType }), + hierarchyDefinition: new CategoriesTreeDefinition({ imodelAccess, viewType, idsCache: new CategoriesTreeIdsCache(imodelAccess, viewType) }), }); } diff --git a/packages/itwin/tree-widget/src/test/trees/categories-tree/CategoriesTreeFiltering.test.ts b/packages/itwin/tree-widget/src/test/trees/categories-tree/CategoriesTreeFiltering.test.ts index 29a2551b87..b11289c365 100644 --- a/packages/itwin/tree-widget/src/test/trees/categories-tree/CategoriesTreeFiltering.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/categories-tree/CategoriesTreeFiltering.test.ts @@ -10,8 +10,10 @@ import { ECSchemaRpcImpl } from "@itwin/ecschema-rpcinterface-impl"; import { PresentationRpcInterface } from "@itwin/presentation-common"; import { HierarchyCacheMode, initialize as initializePresentationTesting, terminate as terminatePresentationTesting } from "@itwin/presentation-testing"; import { CategoriesTreeDefinition } from "../../../tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.js"; +import { CategoriesTreeIdsCache } from "../../../tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.js"; import { buildIModel, + insertDefinitionContainer, insertDrawingCategory, insertDrawingGraphic, insertDrawingModelWithPartition, @@ -19,6 +21,7 @@ import { insertPhysicalModelWithPartition, insertSpatialCategory, insertSubCategory, + insertSubModel, } from "../../IModelUtils.js"; import { createIModelAccess } from "../Common.js"; @@ -43,6 +46,129 @@ describe("Categories tree", () => { await terminatePresentationTesting(); }); + it("finds definition container by label", async function () { + const { imodel, keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer", userLabel: "Test" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { keys: { definitionContainer } }; + }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "3d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( + await CategoriesTreeDefinition.createInstanceKeyPaths({ + imodelAccess, + label: "Test", + viewType, + idsCache, + }), + ).to.deep.eq([{ path: [keys.definitionContainer], options: { autoExpand: true } }]); + }); + + it("finds definition container by label when it is contained by another definition container", async function () { + const { imodel, keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const definitionContainerChild = insertDefinitionContainer({ + builder, + codeValue: "DefinitionContainerChild", + userLabel: "Test", + modelId: definitionModel.id, + }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { keys: { definitionContainer, definitionContainerChild } }; + }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "3d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( + await CategoriesTreeDefinition.createInstanceKeyPaths({ + imodelAccess, + label: "Test", + viewType, + idsCache, + }), + ).to.deep.eq([{ path: [keys.definitionContainer, keys.definitionContainerChild], options: { autoExpand: true } }]); + }); + + it("does not find definition container by label when it doesn't contain categories", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer", userLabel: "Test" }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { keys: { definitionContainer } }; + }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "3d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( + await CategoriesTreeDefinition.createInstanceKeyPaths({ + imodelAccess, + label: "Test", + viewType, + idsCache, + }), + ).to.deep.eq([]); + }); + + it("finds category by label when it is contained by definition container", async function () { + const { imodel, keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", userLabel: "Test", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { keys: { definitionContainer, category } }; + }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "3d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( + await CategoriesTreeDefinition.createInstanceKeyPaths({ + imodelAccess, + label: "Test", + viewType, + idsCache, + }), + ).to.deep.eq([{ path: [keys.definitionContainer, keys.category], options: { autoExpand: true } }]); + }); + + it("finds subCategory by label when its parent category is contained by definition container", async function () { + const { imodel, keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory1 = insertSubCategory({ builder, codeValue: "SubCategory1", parentCategoryId: category.id, modelId: definitionModel.id }); + + return { keys: { definitionContainer, category, subCategory1 } }; + }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "3d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( + await CategoriesTreeDefinition.createInstanceKeyPaths({ + imodelAccess, + label: "SubCategory1", + viewType, + idsCache, + }), + ).to.deep.eq([{ path: [keys.definitionContainer, keys.category, keys.subCategory1], options: { autoExpand: true } }]); + }); + it("finds 3d categories by label containing special SQLite characters", async function () { const { imodel, keys } = await buildIModel(this, async (builder) => { const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); @@ -61,19 +187,25 @@ describe("Categories tree", () => { }; }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "3d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "_", viewType: "3d", + idsCache, }), ).to.deep.eq([{ path: [keys.category1], options: { autoExpand: true } }]); expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "%", viewType: "3d", + idsCache, }), ).to.deep.eq([{ path: [keys.category2], options: { autoExpand: true } }]); }); @@ -97,19 +229,25 @@ describe("Categories tree", () => { }; }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "3d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "_", viewType: "3d", + idsCache, }), ).to.deep.eq([{ path: [keys.category, keys.subCategory1], options: { autoExpand: true } }]); expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "%", viewType: "3d", + idsCache, }), ).to.deep.eq([{ path: [keys.category, keys.subCategory2], options: { autoExpand: true } }]); }); @@ -127,19 +265,26 @@ describe("Categories tree", () => { }, }; }); + + const imodelAccess = createIModelAccess(imodel); + const viewType = "3d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "Test", viewType: "3d", + idsCache, }), ).to.deep.eq([{ path: [keys.category], options: { autoExpand: true } }]); expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "SpatialCategory", viewType: "3d", + idsCache, }), ).to.deep.eq([]); }); @@ -164,27 +309,34 @@ describe("Categories tree", () => { }; }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "3d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "Test", viewType: "3d", + idsCache, }), ).to.deep.eq([{ path: [keys.category], options: { autoExpand: true } }]); expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "SubCategory1", viewType: "3d", + idsCache, }), ).to.deep.eq([{ path: [keys.category, keys.subCategory1], options: { autoExpand: true } }]); expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "SubCategory2", viewType: "3d", + idsCache, }), ).to.deep.eq([{ path: [keys.category, keys.subCategory2], options: { autoExpand: true } }]); }); @@ -207,19 +359,25 @@ describe("Categories tree", () => { }; }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "2d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "_", viewType: "2d", + idsCache, }), ).to.deep.eq([{ path: [keys.category1], options: { autoExpand: true } }]); expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "%", viewType: "2d", + idsCache, }), ).to.deep.eq([{ path: [keys.category2], options: { autoExpand: true } }]); }); @@ -243,19 +401,25 @@ describe("Categories tree", () => { }; }); + const imodelAccess = createIModelAccess(imodel); + const viewType = "2d"; + const idsCache = new CategoriesTreeIdsCache(imodelAccess, viewType); + expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "_", viewType: "2d", + idsCache, }), ).to.deep.eq([{ path: [keys.category, keys.subCategory1], options: { autoExpand: true } }]); expect( await CategoriesTreeDefinition.createInstanceKeyPaths({ - imodelAccess: createIModelAccess(imodel), + imodelAccess, label: "%", viewType: "2d", + idsCache, }), ).to.deep.eq([{ path: [keys.category, keys.subCategory2], options: { autoExpand: true } }]); }); diff --git a/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/CategoriesTreeIdsCache.test.ts b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/CategoriesTreeIdsCache.test.ts new file mode 100644 index 0000000000..06d2f462c4 --- /dev/null +++ b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/CategoriesTreeIdsCache.test.ts @@ -0,0 +1,719 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from "chai"; +import { IModelReadRpcInterface, SnapshotIModelRpcInterface } from "@itwin/core-common"; +import { ECSchemaRpcInterface } from "@itwin/ecschema-rpcinterface-common"; +import { ECSchemaRpcImpl } from "@itwin/ecschema-rpcinterface-impl"; +import { PresentationRpcInterface } from "@itwin/presentation-common"; +import { HierarchyCacheMode, initialize as initializePresentationTesting, terminate as terminatePresentationTesting } from "@itwin/presentation-testing"; +import { CategoriesTreeIdsCache } from "../../../../tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.js"; +import { + buildIModel, + insertDefinitionContainer, + insertPhysicalElement, + insertPhysicalModelWithPartition, + insertSpatialCategory, + insertSubCategory, + insertSubModel, +} from "../../../IModelUtils.js"; +import { createIModelAccess } from "../../Common.js"; + +describe("CategoriesTreeIdsCache", () => { + before(async function () { + await initializePresentationTesting({ + backendProps: { + caching: { + hierarchies: { + mode: HierarchyCacheMode.Memory, + }, + }, + }, + rpcs: [SnapshotIModelRpcInterface, IModelReadRpcInterface, PresentationRpcInterface, ECSchemaRpcInterface], + }); + // eslint-disable-next-line @itwin/no-internal + ECSchemaRpcImpl.register(); + }); + + after(async function () { + await terminatePresentationTesting(); + }); + + describe("getDirectChildDefinitionContainersAndCategories", () => { + it("retruns empty list when definition container contains nothing", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainer }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getDirectChildDefinitionContainersAndCategories([keys.definitionContainer.id])).to.deep.eq({ + categories: [], + definitionContainers: [], + }); + }); + + it("returns empty lists when definition container contains empty definition container", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + return { definitionContainerRoot }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getDirectChildDefinitionContainersAndCategories([keys.definitionContainerRoot.id])).to.deep.eq({ + categories: [], + definitionContainers: [], + }); + }); + + it("returns child definition container when definition container contains definition container, that has categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainerRoot, definitionContainerChild }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getDirectChildDefinitionContainersAndCategories([keys.definitionContainerRoot.id])).to.deep.eq({ + categories: [], + definitionContainers: [keys.definitionContainerChild.id], + }); + }); + + it("returns child categories when definition container contains categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainerRoot, category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getDirectChildDefinitionContainersAndCategories([keys.definitionContainerRoot.id])).to.deep.eq({ + categories: [{ id: keys.category.id, childCount: 1 }], + definitionContainers: [], + }); + }); + + it("returns only categories when definition container contains categories and definition containers that contain nothing", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainerRoot, category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getDirectChildDefinitionContainersAndCategories([keys.definitionContainerRoot.id])).to.deep.eq({ + categories: [{ id: keys.category.id, childCount: 1 }], + definitionContainers: [], + }); + }); + + it("returns child definition container and category when definition container contains categories and definition containers that contain categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const directCategory = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: directCategory.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + + return { definitionContainerRoot, directCategory, definitionModelChild }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getDirectChildDefinitionContainersAndCategories([keys.definitionContainerRoot.id])).to.deep.eq({ + categories: [{ id: keys.directCategory.id, childCount: 1 }], + definitionContainers: [keys.definitionModelChild.id], + }); + }); + + it("returns child categories when definition container with categories is contained by definition container", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + + return { definitionModelChild, indirectCategory }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getDirectChildDefinitionContainersAndCategories([keys.definitionModelChild.id])).to.deep.eq({ + categories: [{ id: keys.indirectCategory.id, childCount: 1 }], + definitionContainers: [], + }); + }); + }); + + describe("getAllContainedCategories", () => { + it("returns empty list when definition container contains nothing", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + + return { definitionContainer }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getAllContainedCategories([keys.definitionContainer.id])).to.deep.eq([]); + }); + + it("returns indirectly contained categories when definition container contains definition container that has categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainerRoot, category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getAllContainedCategories([keys.definitionContainerRoot.id])).to.deep.eq([keys.category.id]); + }); + + it("returns child categories when definition container contains categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainer, category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getAllContainedCategories([keys.definitionContainer.id])).to.deep.eq([keys.category.id]); + }); + + it("returns direct and indirect categories when definition container contains categories and definition containers that contain categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const directCategory = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: directCategory.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + + return { definitionContainerRoot, directCategory, indirectCategory }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + const result = await idsCache.getAllContainedCategories([keys.definitionContainerRoot.id]); + const expectedResult = [keys.indirectCategory.id, keys.directCategory.id]; + expect(expectedResult.every((id) => result.includes(id))).to.be.true; + }); + }); + + describe("getInstanceKeyPaths", () => { + describe("from subCategory id", () => { + it("returns empty list when subcategory doesn't exist", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ subCategoryId: "0x123" })).to.deep.eq([]); + }); + + it("returns path to subCategory when category has subCategory", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + const subCategory = insertSubCategory({ builder, parentCategoryId: category.id, codeValue: "Test SpatialSubCategory" }); + return { subCategory, category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ subCategoryId: keys.subCategory.id })).to.deep.eq([keys.category, keys.subCategory]); + }); + + it("returns path to subCategory when definition container contains category that has subCategory", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + const subCategory = insertSubCategory({ builder, parentCategoryId: category.id, codeValue: "Test SpatialSubCategory", modelId: definitionModel.id }); + return { subCategory, category, definitionContainer }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ subCategoryId: keys.subCategory.id })).to.deep.eq([ + keys.definitionContainer, + keys.category, + keys.subCategory, + ]); + }); + + it("returns path to subCategory when definition container contains definition container that contains category that has subCategory", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "Test SpatialSubCategory", + modelId: definitionModelChild.id, + }); + return { subCategory, category, definitionContainerChild, definitionContainerRoot }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ subCategoryId: keys.subCategory.id })).to.deep.eq([ + keys.definitionContainerRoot, + keys.definitionContainerChild, + keys.category, + keys.subCategory, + ]); + }); + }); + + describe("from category id", () => { + it("returns empty list when category doesn't exist", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ categoryId: "0x123" })).to.deep.eq([]); + }); + + it("returns only category when only category exists", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ categoryId: keys.category.id })).to.deep.eq([keys.category]); + }); + + it("returns path to category when definition container contains category", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category, definitionContainer }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ categoryId: keys.category.id })).to.deep.eq([keys.definitionContainer, keys.category]); + }); + + it("returns path to category when definition container contains definition container that contains category", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category, definitionContainerChild, definitionContainerRoot }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ categoryId: keys.category.id })).to.deep.eq([ + keys.definitionContainerRoot, + keys.definitionContainerChild, + keys.category, + ]); + }); + }); + + describe("from definition container id", () => { + it("returns empty list when definition container doesn't exist", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ definitionContainerId: "0x123" })).to.deep.eq([]); + }); + + it("returns definition container when definition container contains category", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category, definitionContainer }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ definitionContainerId: keys.definitionContainer.id })).to.deep.eq([keys.definitionContainer]); + }); + + it("returns path to definition container when definition container is contained by definition container", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category, definitionContainerChild, definitionContainerRoot }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getInstanceKeyPaths({ definitionContainerId: keys.definitionContainerChild.id })).to.deep.eq([ + keys.definitionContainerRoot, + keys.definitionContainerChild, + ]); + }); + }); + }); + + describe("getAllDefinitionContainersAndCategories", () => { + it("returns empty list when no categories or definition containers exist", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getAllDefinitionContainersAndCategories()).to.deep.eq({ categories: [], definitionContainers: [] }); + }); + + it("returns category when only category and empty definition container exist", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getAllDefinitionContainersAndCategories()).to.deep.eq({ + categories: [keys.category.id], + definitionContainers: [], + }); + }); + + it("returns category when category and definition containers (that dont contain categories) exist", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getAllDefinitionContainersAndCategories()).to.deep.eq({ + categories: [keys.category.id], + definitionContainers: [], + }); + }); + + it("returns both definition containers and their contained category when definition container contains definition container that contains categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainerRoot, definitionContainerChild, category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + const result = await idsCache.getAllDefinitionContainersAndCategories(); + const expectedResult = { + categories: [keys.category.id], + definitionContainers: [keys.definitionContainerRoot.id, keys.definitionContainerChild.id], + }; + expect(result.categories).to.deep.eq(expectedResult.categories); + expect(expectedResult.definitionContainers.every((dc) => result.definitionContainers.includes(dc))).to.be.true; + }); + + it("returns definition container and category when definition container contains category", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainerRoot, category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getAllDefinitionContainersAndCategories()).to.deep.eq({ + categories: [keys.category.id], + definitionContainers: [keys.definitionContainerRoot.id], + }); + }); + + it("returns definition container and category when definition container contains category and definition container that doesn't contain category", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainerRoot, category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getAllDefinitionContainersAndCategories()).to.deep.eq({ + categories: [keys.category.id], + definitionContainers: [keys.definitionContainerRoot.id], + }); + }); + + it("returns both definition containers and categories when definition container contains categories and definition container that contain categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const directCategory = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: directCategory.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + + return { definitionContainerRoot, directCategory, definitionModelChild, indirectCategory }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + const result = await idsCache.getAllDefinitionContainersAndCategories(); + const expectedResult = { + categories: [keys.directCategory.id, keys.indirectCategory.id], + definitionContainers: [keys.definitionModelChild.id, keys.definitionContainerRoot.id], + }; + expect(expectedResult.categories.every((c) => result.categories.includes(c))).to.be.true; + expect(expectedResult.definitionContainers.every((dc) => result.definitionContainers.includes(dc))).to.be.true; + }); + }); + + describe("getRootDefinitionContainersAndCategories", () => { + it("returns empty list when no categories or definition containers exist", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getRootDefinitionContainersAndCategories()).to.deep.eq({ categories: [], definitionContainers: [] }); + }); + + it("returns category when category and definition container that doesn't contain anything exist", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getRootDefinitionContainersAndCategories()).to.deep.eq({ + categories: [{ id: keys.category.id, childCount: 1 }], + definitionContainers: [], + }); + }); + + it("returns category when category and definition container that contains empty definition container exist", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getRootDefinitionContainersAndCategories()).to.deep.eq({ + categories: [{ id: keys.category.id, childCount: 1 }], + definitionContainers: [], + }); + }); + + it("returns only the root definition container when definition container contains definition container that contains categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainerRoot }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getRootDefinitionContainersAndCategories()).to.deep.eq({ + categories: [], + definitionContainers: [keys.definitionContainerRoot.id], + }); + }); + + it("returns definition container when definition container containts category", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + + return { definitionContainerRoot }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getRootDefinitionContainersAndCategories()).to.deep.eq({ + categories: [], + definitionContainers: [keys.definitionContainerRoot.id], + }); + }); + + it("returns root categories and definition containers when root categories and definition containers exist", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerRootNoChildren = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainerNoChild" }); + insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRootNoChildren.id }); + + const definitionContainerRoot2 = insertDefinitionContainer({ builder, codeValue: "Test DefinitionContainer2" }); + const definitionModelRoot2 = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot2.id }); + + const rootCategory1 = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: rootCategory1.id }); + const rootCategory2 = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory2" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: rootCategory2.id }); + + const childCategory = insertSpatialCategory({ builder, codeValue: "Test SpatialCategoryChild", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: childCategory.id }); + const childCategory2 = insertSpatialCategory({ builder, codeValue: "Test SpatialCategoryChild2", modelId: definitionModelRoot2.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: childCategory2.id }); + + return { definitionContainerRoot, rootCategory1, definitionContainerRoot2, rootCategory2 }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + const result = await idsCache.getRootDefinitionContainersAndCategories(); + const expectedResult = { + categories: [ + { id: keys.rootCategory1.id, childCount: 1 }, + { id: keys.rootCategory2.id, childCount: 1 }, + ], + definitionContainers: [keys.definitionContainerRoot.id, keys.definitionContainerRoot2.id], + }; + expect( + expectedResult.categories.every((expectedCategory) => + result.categories.find((category) => category.id === expectedCategory.id && category.childCount === expectedCategory.childCount), + ), + ).to.be.true; + expect(expectedResult.definitionContainers.every((dc) => result.definitionContainers.includes(dc))).to.be.true; + }); + }); + + describe("getSubCategories", () => { + it("returns empty list when category doesn't exist", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getSubCategories("0x123")).to.deep.eq([]); + }); + + it("returns empty list when category has one subCategory", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + return { category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + expect(await idsCache.getSubCategories(keys.category.id)).to.deep.eq([]); + }); + + it("returns subCategories when category has multiple subCategories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ builder, parentCategoryId: category.id, codeValue: "subc 1" }); + + return { subCategory, category }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + const result = await idsCache.getSubCategories(keys.category.id); + expect(result.includes(keys.subCategory.id)).to.be.true; + expect(result.length).to.be.eq(2); + }); + + it("returns only child subCategories when multiple categories have multiple subCategories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + insertSubCategory({ builder, parentCategoryId: category.id, codeValue: "subc 1" }); + + const category2 = insertSpatialCategory({ builder, codeValue: "Test SpatialCategory2" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + const subCategory2 = insertSubCategory({ builder, parentCategoryId: category2.id, codeValue: "subc 2" }); + + return { subCategory2, category2 }; + }); + const idsCache = new CategoriesTreeIdsCache(createIModelAccess(imodel), "3d"); + const result = await idsCache.getSubCategories(keys.category2.id); + expect(result.includes(keys.subCategory2.id)).to.be.true; + expect(result.length).to.be.eq(2); + }); + }); +}); diff --git a/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/CategoriesTreeNode.test.ts b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/CategoriesTreeNode.test.ts new file mode 100644 index 0000000000..ca800e1b65 --- /dev/null +++ b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/CategoriesTreeNode.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from "chai"; +import { CategoriesTreeNode } from "../../../../tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeNode.js"; + +describe("CategoriesTreeNode", () => { + const randomNode = { + extendedData: {}, + }; + const categoryNode = { + extendedData: { + isCategory: 1, + }, + }; + const subCategoryNode = { + extendedData: { + isSubCategory: 1, + }, + }; + const definitionContainerNode = { + extendedData: { + isDefinitionContainer: 1, + }, + }; + + it("isCategoryNode", () => { + expect(CategoriesTreeNode.isCategoryNode(randomNode)).to.be.false; + expect(CategoriesTreeNode.isCategoryNode(categoryNode)).to.be.true; + expect(CategoriesTreeNode.isCategoryNode(subCategoryNode)).to.be.false; + expect(CategoriesTreeNode.isCategoryNode(definitionContainerNode)).to.be.false; + }); + + it("isSubCategoryNode", () => { + expect(CategoriesTreeNode.isSubCategoryNode(randomNode)).to.be.false; + expect(CategoriesTreeNode.isSubCategoryNode(categoryNode)).to.be.false; + expect(CategoriesTreeNode.isSubCategoryNode(subCategoryNode)).to.be.true; + expect(CategoriesTreeNode.isSubCategoryNode(definitionContainerNode)).to.be.false; + }); + + it("isDefinitionContainerNode", () => { + expect(CategoriesTreeNode.isDefinitionContainerNode(randomNode)).to.be.false; + expect(CategoriesTreeNode.isDefinitionContainerNode(categoryNode)).to.be.false; + expect(CategoriesTreeNode.isDefinitionContainerNode(subCategoryNode)).to.be.false; + expect(CategoriesTreeNode.isDefinitionContainerNode(definitionContainerNode)).to.be.true; + }); +}); diff --git a/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/CategoriesVisibilityHandler.test.ts b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/CategoriesVisibilityHandler.test.ts new file mode 100644 index 0000000000..4c401d3146 --- /dev/null +++ b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/CategoriesVisibilityHandler.test.ts @@ -0,0 +1,1239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + + +import { IModelReadRpcInterface, SnapshotIModelRpcInterface } from "@itwin/core-common"; +import { ECSchemaRpcInterface } from "@itwin/ecschema-rpcinterface-common"; +import { ECSchemaRpcImpl } from "@itwin/ecschema-rpcinterface-impl"; +import { PresentationRpcInterface } from "@itwin/presentation-common"; +import { createIModelHierarchyProvider } from "@itwin/presentation-hierarchies"; +import { HierarchyCacheMode, initialize as initializePresentationTesting, terminate as terminatePresentationTesting } from "@itwin/presentation-testing"; +import { CategoriesTreeDefinition } from "../../../../tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.js"; +import { CategoriesTreeIdsCache } from "../../../../tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.js"; +import { CategoriesVisibilityHandler } from "../../../../tree-widget-react/components/trees/categories-tree/internal/CategoriesVisibilityHandler.js"; +import { + buildIModel, + insertDefinitionContainer, + insertPhysicalElement, + insertPhysicalModelWithPartition, + insertSpatialCategory, + insertSubCategory, + insertSubModel, +} from "../../../IModelUtils.js"; +import { TestUtils } from "../../../TestUtils.js"; +import { createIModelAccess } from "../../Common.js"; +import { createCategoryHierarchyNode, createDefinitionContainerHierarchyNode, createSubCategoryHierarchyNode, createViewportStub } from "./Utils.js"; +import { validateHierarchyVisibility } from "./VisibilityValidation.js"; + +import type { IModelConnection } from "@itwin/core-frontend"; +import type { HierarchyNodeIdentifiersPath } from "@itwin/presentation-hierarchies"; + +describe("CategoriesVisibilityHandler", () => { + before(async () => { + await initializePresentationTesting({ + backendProps: { + caching: { + hierarchies: { + mode: HierarchyCacheMode.Memory, + }, + }, + }, + rpcs: [SnapshotIModelRpcInterface, IModelReadRpcInterface, PresentationRpcInterface, ECSchemaRpcInterface], + }); + await TestUtils.initialize(); + // eslint-disable-next-line @itwin/no-internal + ECSchemaRpcImpl.register(); + }); + + after(async () => { + await terminatePresentationTesting(); + TestUtils.terminate(); + }); + + async function createCommonProps(imodel: IModelConnection, isVisibleOnInitialize: boolean) { + const imodelAccess = createIModelAccess(imodel); + const idsCache = new CategoriesTreeIdsCache(imodelAccess, "3d"); + + const viewport = await createViewportStub({ idsCache, isVisibleOnInitialize }); + return { + imodelAccess, + viewport, + idsCache, + }; + } + + function createProvider(props: { + idsCache: CategoriesTreeIdsCache; + imodelAccess: ReturnType; + filterPaths?: HierarchyNodeIdentifiersPath[]; + }) { + return createIModelHierarchyProvider({ + hierarchyDefinition: new CategoriesTreeDefinition({ ...props, viewType: "3d" }), + imodelAccess: props.imodelAccess, + ...(props.filterPaths ? { filtering: { paths: props.filterPaths } } : undefined), + }); + } + + async function createVisibilityTestData({ imodel, isVisibleOnInitialize }: { imodel: IModelConnection; isVisibleOnInitialize: boolean }) { + const commonProps = await createCommonProps(imodel, isVisibleOnInitialize); + const handler = new CategoriesVisibilityHandler(commonProps); + const provider = createProvider({ ...commonProps }); + return { + handler, + provider, + ...commonProps, + [Symbol.dispose]() { + handler[Symbol.dispose](); + provider[Symbol.dispose](); + }, + }; + } + + describe("enabling visibility", () => { + const isVisibleOnInitialize = false; + + it("by default everything is hidden", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + insertSubCategory({ builder, parentCategoryId: category.id, codeValue: "subCategory", modelId: definitionModel.id }); + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: "all-hidden", + }); + }); + describe("definitionContainers", () => { + it("showing definition container makes it and all of its contained elements visible", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + + const directCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory1", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: directCategory.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + const indirectSubCategory = insertSubCategory({ + builder, + parentCategoryId: indirectCategory.id, + codeValue: "subCategory", + modelId: definitionModelChild.id, + }); + return { definitionContainerRoot, definitionContainerChild, directCategory, indirectCategory, indirectSubCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerRoot.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: "all-visible", + }); + viewport.validateChangesCalls( + [{ categoriesToChange: [keys.directCategory.id, keys.indirectCategory.id], isVisible: true, enableAllSubCategories: true }], + [], + ); + }); + + it("showing definition container makes it and all of its contained elements visible and doesn't affect non contained definition containers", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + const indirectSubCategory = insertSubCategory({ + builder, + parentCategoryId: indirectCategory.id, + codeValue: "subCategory", + modelId: definitionModelChild.id, + }); + + const definitionContainerRoot2 = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot2" }); + const definitionModelRoot2 = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot2.id }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelRoot2.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + const subCategory2 = insertSubCategory({ builder, parentCategoryId: category2.id, codeValue: "subCategory2", modelId: definitionModelRoot2.id }); + + return { + definitionContainerRoot, + definitionContainerChild, + indirectCategory, + indirectSubCategory, + definitionContainerRoot2, + category2, + subCategory2, + }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerRoot.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot2.id]: "hidden", + [keys.definitionContainerRoot.id]: "visible", + [keys.definitionContainerChild.id]: "visible", + [keys.category2.id]: "hidden", + [keys.indirectCategory.id]: "visible", + [keys.subCategory2.id]: "hidden", + [keys.indirectSubCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.indirectCategory.id], isVisible: true, enableAllSubCategories: true }], []); + }); + + it("showing definition container makes it and all of its contained elements visible, and parent container partially visible if it has more direct child categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + + const directCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory1", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: directCategory.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + return { definitionContainerRoot, definitionContainerChild, directCategory, indirectCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerChild.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.definitionContainerChild.id]: "visible", + [keys.directCategory.id]: "hidden", + [keys.indirectCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.indirectCategory.id], isVisible: true, enableAllSubCategories: true }], []); + }); + + it("showing definition container makes it and all of its contained elements visible, and parent container partially visible if it has more definition containers", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + + const definitionContainerChild2 = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild2", modelId: definitionModelRoot.id }); + const definitionModelChild2 = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild2.id }); + const indirectCategory2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelChild2.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory2.id }); + return { definitionContainerRoot, definitionContainerChild, indirectCategory2, indirectCategory, definitionContainerChild2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerChild.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.definitionContainerChild.id]: "visible", + [keys.definitionContainerChild2.id]: "hidden", + [keys.indirectCategory2.id]: "hidden", + [keys.indirectCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.indirectCategory.id], isVisible: true, enableAllSubCategories: true }], []); + }); + + it("showing child definition container makes it, all of its contained elements and its parent definition container visible", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + const indirectSubCategory = insertSubCategory({ + builder, + parentCategoryId: indirectCategory.id, + codeValue: "subCategory", + modelId: definitionModelChild.id, + }); + + return { definitionContainerRoot, definitionContainerChild, indirectCategory, indirectSubCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerChild.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: "all-visible", + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.indirectCategory.id], isVisible: true, enableAllSubCategories: true }], []); + }); + }); + + describe("categories", () => { + it("showing category makes it and all of its subCategories visible", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + return { category, subCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: "all-visible", + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: true, enableAllSubCategories: true }], []); + }); + + it("showing category makes it, all of its contained subCategories visible and doesn't affect other categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + const subCategory2 = insertSubCategory({ + builder, + parentCategoryId: category2.id, + codeValue: "subCategory2", + }); + + return { category, category2, subCategory, subCategory2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.category2.id]: "hidden", + [keys.category.id]: "visible", + [keys.subCategory2.id]: "hidden", + [keys.subCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: true, enableAllSubCategories: true }], []); + }); + + it("showing category makes it, all of its contained subCategories visible and doesn't affect non related definition container", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + const subCategory2 = insertSubCategory({ + builder, + parentCategoryId: category2.id, + codeValue: "subCategory2", + modelId: definitionContainer.id, + }); + + return { definitionContainer, category, category2, subCategory, subCategory2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainer.id]: "hidden", + [keys.category2.id]: "hidden", + [keys.category.id]: "visible", + [keys.subCategory2.id]: "hidden", + [keys.subCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: true, enableAllSubCategories: true }], []); + }); + + it("showing category makes it and all of its subcategories visible, and parent container partially visible if it has more direct child categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + modelId: definitionModelRoot.id, + }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + const subCategory2 = insertSubCategory({ + builder, + parentCategoryId: category2.id, + codeValue: "subCategory2", + modelId: definitionModelRoot.id, + }); + return { definitionContainerRoot, category, category2, subCategory, subCategory2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.category2.id]: "hidden", + [keys.category.id]: "visible", + [keys.subCategory2.id]: "hidden", + [keys.subCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: true, enableAllSubCategories: true }], []); + }); + + it("showing category makes it and all of its subCategories visible, and parent container partially visible if it has more definition containers", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + modelId: definitionModelRoot.id, + }); + return { definitionContainerRoot, definitionContainerChild, category, indirectCategory, subCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.definitionContainerChild.id]: "hidden", + [keys.indirectCategory.id]: "hidden", + [keys.category.id]: "visible", + [keys.subCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: true, enableAllSubCategories: true }], []); + }); + }); + + describe("subCategories", () => { + it("showing subCategory makes it visible and its parent category partially visible, and doesn't affect other subCategories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + const subCategory2 = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory2", + }); + return { category, subCategory, subCategory2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createSubCategoryHierarchyNode(keys.subCategory.id, keys.category.id), true); + + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.category.id]: "partial", + [keys.subCategory.id]: "visible", + [keys.subCategory2.id]: "hidden", + }, + }); + viewport.validateChangesCalls( + [{ categoriesToChange: [keys.category.id], isVisible: true, enableAllSubCategories: false }], + [{ subCategoryId: keys.subCategory.id, isVisible: true }], + ); + }); + + it("showing subCategory makes it visible and its parent category partially visible, and doesn't affect other categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + return { category, subCategory, category2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createSubCategoryHierarchyNode(keys.subCategory.id, keys.category.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.category2.id]: "hidden", + [keys.category.id]: "partial", + [keys.subCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls( + [{ categoriesToChange: [keys.category.id], isVisible: true, enableAllSubCategories: false }], + [{ subCategoryId: keys.subCategory.id, isVisible: true }], + ); + }); + + it("showing subCategory makes it visible and parents partially visible", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + modelId: definitionModelRoot.id, + }); + return { category, subCategory, definitionContainerRoot }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createSubCategoryHierarchyNode(keys.subCategory.id, keys.category.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.category.id]: "partial", + [keys.subCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls( + [{ categoriesToChange: [keys.category.id], isVisible: true, enableAllSubCategories: false }], + [{ subCategoryId: keys.subCategory.id, isVisible: true }], + ); + }); + + it("showing subCategory makes it visible and doesn't affect non related definition containers", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + const categoryOfDefinitionContainer = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: categoryOfDefinitionContainer.id }); + const subCategoryOfDefinitionContainer = insertSubCategory({ + builder, + parentCategoryId: categoryOfDefinitionContainer.id, + codeValue: "subCategory2", + modelId: definitionModelRoot.id, + }); + return { category, subCategory, definitionContainerRoot, categoryOfDefinitionContainer, subCategoryOfDefinitionContainer }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createSubCategoryHierarchyNode(keys.subCategory.id, keys.category.id), true); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "hidden", + [keys.categoryOfDefinitionContainer.id]: "hidden", + [keys.subCategoryOfDefinitionContainer.id]: "hidden", + [keys.category.id]: "partial", + [keys.subCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls( + [{ categoriesToChange: [keys.category.id], isVisible: true, enableAllSubCategories: false }], + [{ subCategoryId: keys.subCategory.id, isVisible: true }], + ); + }); + }); + }); + + describe("disabling visibility", () => { + const isVisibleOnInitialize = true; + + it("by default everything is visible", async function () { + const { imodel } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainer" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + insertSubCategory({ builder, parentCategoryId: category.id, codeValue: "subCategory", modelId: definitionModel.id }); + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: "all-visible", + }); + }); + describe("definitionContainers", () => { + it("hiding definition container makes it and all of its contained elements hidden", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + + const directCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory1", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: directCategory.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + const indirectSubCategory = insertSubCategory({ + builder, + parentCategoryId: indirectCategory.id, + codeValue: "subCategory", + modelId: definitionModelChild.id, + }); + return { definitionContainerRoot, definitionContainerChild, directCategory, indirectCategory, indirectSubCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerRoot.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: "all-hidden", + }); + viewport.validateChangesCalls( + [{ categoriesToChange: [keys.directCategory.id, keys.indirectCategory.id], isVisible: false, enableAllSubCategories: false }], + [], + ); + }); + + it("hiding definition container makes it and all of its contained elements hidden and doesn't affect non contained definition containers", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + const indirectSubCategory = insertSubCategory({ + builder, + parentCategoryId: indirectCategory.id, + codeValue: "subCategory", + modelId: definitionModelChild.id, + }); + + const definitionContainerRoot2 = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot2" }); + const definitionModelRoot2 = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot2.id }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelRoot2.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + const subCategory2 = insertSubCategory({ builder, parentCategoryId: category2.id, codeValue: "subCategory2", modelId: definitionModelRoot2.id }); + + return { + definitionContainerRoot, + definitionContainerChild, + indirectCategory, + indirectSubCategory, + definitionContainerRoot2, + category2, + subCategory2, + }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerRoot.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot2.id]: "visible", + [keys.definitionContainerRoot.id]: "hidden", + [keys.definitionContainerChild.id]: "hidden", + [keys.indirectCategory.id]: "hidden", + [keys.category2.id]: "visible", + [keys.indirectSubCategory.id]: "hidden", + [keys.subCategory2.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.indirectCategory.id], isVisible: false, enableAllSubCategories: false }], []); + }); + + it("hiding definition container makes it and all of its contained elements hidden, and parent container partially visible if it has more direct child categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + + const directCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory1", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: directCategory.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + return { definitionContainerRoot, definitionContainerChild, directCategory, indirectCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerChild.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.definitionContainerChild.id]: "hidden", + [keys.indirectCategory.id]: "hidden", + [keys.directCategory.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.indirectCategory.id], isVisible: false, enableAllSubCategories: false }], []); + }); + + it("hiding definition container makes it and all of its contained elements hidden, and parent container partially visible if it has more definition containers", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + + const definitionContainerChild2 = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild2", modelId: definitionModelRoot.id }); + const definitionModelChild2 = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild2.id }); + const indirectCategory2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelChild2.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory2.id }); + return { definitionContainerRoot, definitionContainerChild, indirectCategory2, indirectCategory, definitionContainerChild2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerChild.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.definitionContainerChild.id]: "hidden", + [keys.definitionContainerChild2.id]: "visible", + [keys.indirectCategory.id]: "hidden", + [keys.indirectCategory2.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.indirectCategory.id], isVisible: false, enableAllSubCategories: false }], []); + }); + + it("hiding child definition container makes it, all of its contained elements and its parent definition container hidden", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + const indirectSubCategory = insertSubCategory({ + builder, + parentCategoryId: indirectCategory.id, + codeValue: "subCategory", + modelId: definitionModelChild.id, + }); + + return { definitionContainerRoot, definitionContainerChild, indirectCategory, indirectSubCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createDefinitionContainerHierarchyNode(keys.definitionContainerChild.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: "all-hidden", + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.indirectCategory.id], isVisible: false, enableAllSubCategories: false }], []); + }); + }); + + describe("categories", () => { + it("hiding category makes it and all of its subCategories hidden", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + return { category, subCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: "all-hidden", + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: false, enableAllSubCategories: false }], []); + }); + + it("hiding category makes it, all of its contained subCategories hidden and doesn't affect other categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + const subCategory2 = insertSubCategory({ + builder, + parentCategoryId: category2.id, + codeValue: "subCategory2", + }); + + return { category, category2, subCategory, subCategory2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.category.id]: "hidden", + [keys.category2.id]: "visible", + [keys.subCategory2.id]: "visible", + [keys.subCategory.id]: "hidden", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: false, enableAllSubCategories: false }], []); + }); + + it("hiding category makes it, all of its contained subCategories hidden and doesn't affect non related definition container", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + + const definitionContainer = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModel = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainer.id }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModel.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + const subCategory2 = insertSubCategory({ + builder, + parentCategoryId: category2.id, + codeValue: "subCategory2", + modelId: definitionContainer.id, + }); + + return { definitionContainer, category, category2, subCategory, subCategory2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainer.id]: "visible", + [keys.category2.id]: "visible", + [keys.category.id]: "hidden", + [keys.subCategory2.id]: "visible", + [keys.subCategory.id]: "hidden", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: false, enableAllSubCategories: false }], []); + }); + + it("hiding category makes it and all of its subcategories hidden, and parent container partially visible if it has more direct child categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + modelId: definitionModelRoot.id, + }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + const subCategory2 = insertSubCategory({ + builder, + parentCategoryId: category2.id, + codeValue: "subCategory2", + modelId: definitionModelRoot.id, + }); + return { definitionContainerRoot, category, category2, subCategory, subCategory2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.category.id]: "hidden", + [keys.category2.id]: "visible", + [keys.subCategory.id]: "hidden", + [keys.subCategory2.id]: "visible", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: false, enableAllSubCategories: false }], []); + }); + + it("hiding category makes it and all of its subCategories hidden, and parent container partially visible if it has more definition containers", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const definitionContainerChild = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerChild", modelId: definitionModelRoot.id }); + const definitionModelChild = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerChild.id }); + const indirectCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelChild.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: indirectCategory.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + modelId: definitionModelRoot.id, + }); + return { definitionContainerRoot, definitionContainerChild, category, indirectCategory, subCategory }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createCategoryHierarchyNode(keys.category.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.definitionContainerChild.id]: "visible", + [keys.category.id]: "hidden", + [keys.indirectCategory.id]: "visible", + [keys.subCategory.id]: "hidden", + }, + }); + viewport.validateChangesCalls([{ categoriesToChange: [keys.category.id], isVisible: false, enableAllSubCategories: false }], []); + }); + }); + + describe("subCategories", () => { + it("hiding subCategory makes it hidden and its parent category partially visible, and doesn't affect other subCategories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + const subCategory2 = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory2", + }); + return { category, subCategory, subCategory2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createSubCategoryHierarchyNode(keys.subCategory.id, keys.category.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.category.id]: "partial", + [keys.subCategory.id]: "hidden", + [keys.subCategory2.id]: "visible", + }, + }); + viewport.validateChangesCalls([], [{ subCategoryId: keys.subCategory.id, isVisible: false }]); + }); + + it("hiding subCategory makes it hidden and its parent category partially visible, and doesn't affect other categories", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + const category2 = insertSpatialCategory({ builder, codeValue: "SpatialCategory2" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category2.id }); + return { category, subCategory, category2 }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createSubCategoryHierarchyNode(keys.subCategory.id, keys.category.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.category.id]: "partial", + [keys.category2.id]: "visible", + [keys.subCategory.id]: "hidden", + }, + }); + viewport.validateChangesCalls([], [{ subCategoryId: keys.subCategory.id, isVisible: false }]); + }); + + it("hiding subCategory makes it hidden and parents partially visible", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + modelId: definitionModelRoot.id, + }); + return { category, subCategory, definitionContainerRoot }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createSubCategoryHierarchyNode(keys.subCategory.id, keys.category.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "partial", + [keys.category.id]: "partial", + [keys.subCategory.id]: "hidden", + }, + }); + viewport.validateChangesCalls([], [{ subCategoryId: keys.subCategory.id, isVisible: false }]); + }); + + it("hiding subCategory makes it hidden and doesn't affect non related definition containers", async function () { + const { imodel, ...keys } = await buildIModel(this, async (builder) => { + const physicalModel = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel" }); + const definitionContainerRoot = insertDefinitionContainer({ builder, codeValue: "DefinitionContainerRoot" }); + const definitionModelRoot = insertSubModel({ builder, classFullName: "BisCore.DefinitionModel", modeledElementId: definitionContainerRoot.id }); + + const category = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: category.id }); + const subCategory = insertSubCategory({ + builder, + parentCategoryId: category.id, + codeValue: "subCategory", + }); + const categoryOfDefinitionContainer = insertSpatialCategory({ builder, codeValue: "SpatialCategory2", modelId: definitionModelRoot.id }); + insertPhysicalElement({ builder, modelId: physicalModel.id, categoryId: categoryOfDefinitionContainer.id }); + const subCategoryOfDefinitionContainer = insertSubCategory({ + builder, + parentCategoryId: categoryOfDefinitionContainer.id, + codeValue: "subCategory2", + modelId: definitionModelRoot.id, + }); + return { category, subCategory, definitionContainerRoot, categoryOfDefinitionContainer, subCategoryOfDefinitionContainer }; + }); + + using visibilityTestData = await createVisibilityTestData({ imodel, isVisibleOnInitialize }); + const { handler, provider, viewport } = visibilityTestData; + + await handler.changeVisibility(createSubCategoryHierarchyNode(keys.subCategory.id, keys.category.id), false); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + expectations: { + [keys.definitionContainerRoot.id]: "visible", + [keys.categoryOfDefinitionContainer.id]: "visible", + [keys.subCategoryOfDefinitionContainer.id]: "visible", + [keys.category.id]: "partial", + [keys.subCategory.id]: "hidden", + }, + }); + viewport.validateChangesCalls([], [{ subCategoryId: keys.subCategory.id, isVisible: false }]); + }); + }); + }); +}); diff --git a/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/Utils.ts b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/Utils.ts new file mode 100644 index 0000000000..9c06673e30 --- /dev/null +++ b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/Utils.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from "chai"; +import sinon from "sinon"; +import { BeEvent } from "@itwin/core-bentley"; +import { PerModelCategoryVisibility } from "@itwin/core-frontend"; + +import type { Id64Array, Id64String } from "@itwin/core-bentley"; +import type { Viewport } from "@itwin/core-frontend"; +import type { NonGroupingHierarchyNode } from "@itwin/presentation-hierarchies"; +import type { CategoriesTreeIdsCache } from "../../../../tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.js"; + +/** @internal */ +export function createCategoryHierarchyNode(categoryId: Id64String, hasChildren = false): NonGroupingHierarchyNode { + return { + key: { + type: "instances", + instanceKeys: [{ className: "bis:SpatialCategory", id: categoryId }], + }, + children: hasChildren, + label: "", + parentKeys: [], + extendedData: { + isCategory: true, + }, + }; +} + +/** @internal */ +export function createSubCategoryHierarchyNode(subCategoryId: Id64String, categoryId: Id64String): NonGroupingHierarchyNode { + return { + key: { + type: "instances", + instanceKeys: [{ className: "bis:SubCategory", id: subCategoryId }], + }, + children: false, + label: "", + parentKeys: [], + extendedData: { + isSubCategory: true, + categoryId, + }, + }; +} + +/** @internal */ +export function createDefinitionContainerHierarchyNode(definitionContainerId: Id64String): NonGroupingHierarchyNode { + return { + key: { + type: "instances", + instanceKeys: [{ className: "bis:DefinitionContainer", id: definitionContainerId }], + }, + children: true, + label: "", + parentKeys: [], + extendedData: { + isDefinitionContainer: true, + }, + }; +} + + +interface ViewportStubValidation { + /** + * Checks if `changeCategoryDisplay` and `changeSubCategoryDisplay` get called with appropriate params + * + * @param categories categories parameters that `changeCategoryDisplay` should be called with + * @param subCategories subcategories parameters that `changeSubCategoryDisplay` should be called with + */ + validateChangesCalls: ( + categories: { categoriesToChange: Id64Array; isVisible: boolean; enableAllSubCategories: boolean }[], + subCategories: { subCategoryId: Id64String; isVisible: boolean }[], + ) => void; +} + +/** + * Creates a stubbed `Viewport` with that has only necessary properties defined for determening CategoriesTree visibility. + * + * This stub allows changing and saving the display of categories and subcategories + * @returns stubbed `Viewport` + */ +export async function createViewportStub(props: { + idsCache: CategoriesTreeIdsCache; + isVisibleOnInitialize: boolean; +}): Promise { + const subCategoriesMap = new Map(); + + const categoriesMap = new Map< + Id64String, + { + subCategories: Id64Array; + isVisible: boolean; + } + >(); + + const { categories: categoriesFromCache } = await props.idsCache.getAllDefinitionContainersAndCategories(); + for (const category of categoriesFromCache) { + const subCategoriesFromCache = await props.idsCache.getSubCategories(category); + subCategoriesFromCache.forEach((subCategoryId) => { + subCategoriesMap.set(subCategoryId, props.isVisibleOnInitialize); + }); + categoriesMap.set(category, { isVisible: props.isVisibleOnInitialize, subCategories: subCategoriesFromCache }); + } + const changeCategoryDisplayStub = sinon.stub().callsFake((categoriesToChange: Id64Array, isVisible: boolean, enableAllSubCategories: boolean) => { + for (const category of categoriesToChange) { + const value = categoriesMap.get(category); + if (value) { + value.isVisible = isVisible; + if (enableAllSubCategories) { + for (const subCategory of value.subCategories) { + subCategoriesMap.set(subCategory, true); + } + } + } + } + }); + + const changeSubCategoryDisplayStub = sinon.stub().callsFake((subCategoryId: Id64String, isVisible: boolean) => { + subCategoriesMap.set(subCategoryId, isVisible); + }); + + return { + isSubCategoryVisible: sinon.stub().callsFake((subCategoryId: Id64String) => !!subCategoriesMap.get(subCategoryId)), + iModel: { + categories: { + getCategoryInfo: sinon.stub().callsFake(async (ids: Id64Array) => { + const subCategories = []; + for (const id of ids) { + const subCategoriesToUse = categoriesMap.get(id); + if (subCategoriesToUse !== undefined) { + subCategories.push(...subCategoriesToUse.subCategories); + } + } + return [ + { + subCategories: subCategories.map((subCategory) => { + return { + id: subCategory, + }; + }), + }, + ]; + }), + }, + }, + view: { + viewsCategory: sinon.stub().callsFake((categoryId: Id64String) => !!categoriesMap.get(categoryId)?.isVisible), + }, + changeSubCategoryDisplay: changeSubCategoryDisplayStub, + changeCategoryDisplay: changeCategoryDisplayStub, + perModelCategoryVisibility: { + getOverride: sinon.fake.returns(PerModelCategoryVisibility.Override.None), + setOverride: sinon.fake(), + clearOverrides: sinon.fake(), + *[Symbol.iterator]() {}, + }, + onDisplayStyleChanged: new BeEvent<() => void>(), + onViewedCategoriesChanged: new BeEvent<() => void>(), + validateChangesCalls( + categoriesToValidate: { categoriesToChange: Id64Array; isVisible: boolean; enableAllSubCategories: boolean }[], + subCategories: { subCategoryId: Id64String; isVisible: boolean }[], + ) { + for (const category of categoriesToValidate) { + expect(changeCategoryDisplayStub).to.be.calledWith(category.categoriesToChange, category.isVisible, category.enableAllSubCategories); + } + for (const subCategory of subCategories) { + expect(changeSubCategoryDisplayStub).to.be.calledWith(subCategory.subCategoryId, subCategory.isVisible); + } + }, + } as unknown as Viewport & ViewportStubValidation; +} diff --git a/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/VisibilityValidation.ts b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/VisibilityValidation.ts new file mode 100644 index 0000000000..5d92cf8c2c --- /dev/null +++ b/packages/itwin/tree-widget/src/test/trees/categories-tree/internal/VisibilityValidation.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from "chai"; +import { EMPTY, expand, from, mergeMap } from "rxjs"; +import { HierarchyNode } from "@itwin/presentation-hierarchies"; +import { CategoriesTreeNode } from "../../../../tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeNode.js"; +import { toVoidPromise } from "../../../../tree-widget-react/components/trees/common/Rxjs.js"; + +import type { Viewport } from "@itwin/core-frontend"; +import type { HierarchyProvider } from "@itwin/presentation-hierarchies"; +import type { Visibility } from "../../../../tree-widget-react/components/trees/common/Tooltip.js"; +import type { HierarchyVisibilityHandler } from "../../../../tree-widget-react/components/trees/common/UseHierarchyVisibility.js"; + +interface VisibilityExpectations { + [id: string]: Visibility; +} + +export interface ValidateNodeProps { + handler: HierarchyVisibilityHandler; + viewport: Viewport; + expectations: "all-visible" | "all-hidden" | VisibilityExpectations; +} + +export async function validateNodeVisibility({ node, handler, expectations }: ValidateNodeProps & { node: HierarchyNode }) { + const actualVisibility = await handler.getVisibilityStatus(node); + if (!HierarchyNode.isInstancesNode(node)) { + throw new Error(`Expected hierarchy to only have instance nodes, got ${JSON.stringify(node)}`); + } + + if (expectations === "all-hidden" || expectations === "all-visible") { + expect(actualVisibility.state).to.eq(expectations === "all-hidden" ? "hidden" : "visible"); + return; + } + + const { id } = node.key.instanceKeys[0]; + + if (CategoriesTreeNode.isCategoryNode(node)) { + expect(actualVisibility.state).to.eq(expectations[id]); + return; + } + if (CategoriesTreeNode.isSubCategoryNode(node)) { + // One subCategory gets added when category is inserted + if (expectations[id] !== undefined) { + expect(actualVisibility.state).to.eq(expectations[id]); + } + return; + } + if (CategoriesTreeNode.isDefinitionContainerNode(node)) { + expect(actualVisibility.state).to.eq(expectations[id]); + return; + } + + throw new Error(`Expected hierarchy to contain only definitionContainers, categories and subcategories, got ${JSON.stringify(node)}`); +} + +export async function validateHierarchyVisibility({ + provider, + ...props +}: ValidateNodeProps & { + provider: HierarchyProvider; +}) { + await toVoidPromise( + from(provider.getNodes({ parentNode: undefined })).pipe( + expand((node) => (node.children ? provider.getNodes({ parentNode: node }) : EMPTY)), + mergeMap(async (node) => validateNodeVisibility({ ...props, node })), + ), + ); +} diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts index 53c0a3053e..deaf1ba5a2 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts @@ -3288,7 +3288,7 @@ describe("HierarchyBasedVisibilityHandler", () => { }); }); -/** Copied from https://github.com/iTwin/appui/blob/master/test-apps/appui-test-app/appui-test-handlers/src/createBlankConnection.ts#L26 */ +/** Copied from https://github.com/iTwin/appui/blob/c3683b8acef46572c661c4fa1b7933747a76d3c1/apps/test-providers/src/createBlankConnection.ts#L26 */ function createBlankViewState(iModel: IModelConnection) { const ext = iModel.projectExtents; const viewState = SpatialViewState.createBlank(iModel, ext.low, ext.high.minus(ext.low)); diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.ts index 067323b977..d68ac4e5d9 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.ts @@ -3,15 +3,22 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ +import { defer, EMPTY, from, lastValueFrom, map, mergeMap, toArray } from "rxjs"; import { createNodesQueryClauseFactory, createPredicateBasedHierarchyDefinition } from "@itwin/presentation-hierarchies"; import { createBisInstanceLabelSelectClauseFactory, ECSql } from "@itwin/presentation-shared"; import { FilterLimitExceededError } from "../common/TreeErrors.js"; +import { getClassesByView } from "./internal/CategoriesTreeIdsCache.js"; +import { DEFINITION_CONTAINER_CLASS, DEFINITION_ELEMENT_CLASS, SUB_CATEGORY_CLASS } from "./internal/ClassNameDefinitions.js"; -import type { ECClassHierarchyInspector, ECSchemaProvider, IInstanceLabelSelectClauseFactory } from "@itwin/presentation-shared"; +import type { Observable } from "rxjs"; +import type { Id64Array, Id64String } from "@itwin/core-bentley"; +import type { ECClassHierarchyInspector, ECSchemaProvider, IInstanceLabelSelectClauseFactory, InstanceKey } from "@itwin/presentation-shared"; +import type { CategoriesTreeIdsCache } from "./internal/CategoriesTreeIdsCache.js"; import type { DefineHierarchyLevelProps, DefineInstanceNodeChildHierarchyLevelProps, DefineRootHierarchyLevelProps, + GenericInstanceFilter, HierarchyDefinition, HierarchyFilteringPath, HierarchyLevelDefinition, @@ -22,34 +29,31 @@ import type { const MAX_FILTERING_INSTANCE_KEY_COUNT = 100; interface CategoriesTreeDefinitionProps { - imodelAccess: ECSchemaProvider & ECClassHierarchyInspector; + imodelAccess: ECSchemaProvider & ECClassHierarchyInspector & LimitingECSqlQueryExecutor; viewType: "2d" | "3d"; + idsCache: CategoriesTreeIdsCache; } interface CategoriesTreeInstanceKeyPathsFromInstanceLabelProps { imodelAccess: ECClassHierarchyInspector & LimitingECSqlQueryExecutor; label: string; viewType: "2d" | "3d"; + limit?: number | "unbounded"; + idsCache: CategoriesTreeIdsCache; } export class CategoriesTreeDefinition implements HierarchyDefinition { - private _impl: HierarchyDefinition; + private _impl: Promise | undefined; private _selectQueryFactory: NodesQueryClauseFactory; private _nodeLabelSelectClauseFactory: IInstanceLabelSelectClauseFactory; + private _idsCache: CategoriesTreeIdsCache; + private _viewType: "2d" | "3d"; + private _iModelAccess: ECSchemaProvider & ECClassHierarchyInspector & LimitingECSqlQueryExecutor; public constructor(props: CategoriesTreeDefinitionProps) { - this._impl = createPredicateBasedHierarchyDefinition({ - classHierarchyInspector: props.imodelAccess, - hierarchy: { - rootNodes: async (requestProps) => this.createRootHierarchyLevelDefinition({ ...requestProps, viewType: props.viewType }), - childNodes: [ - { - parentInstancesNodePredicate: "BisCore.Category", - definitions: async (requestProps: DefineInstanceNodeChildHierarchyLevelProps) => this.createSubcategoryQuery(requestProps), - }, - ], - }, - }); + this._iModelAccess = props.imodelAccess; + this._viewType = props.viewType; + this._idsCache = props.idsCache; this._nodeLabelSelectClauseFactory = createBisInstanceLabelSelectClauseFactory({ classHierarchyInspector: props.imodelAccess }); this._selectQueryFactory = createNodesQueryClauseFactory({ imodelAccess: props.imodelAccess, @@ -57,22 +61,114 @@ export class CategoriesTreeDefinition implements HierarchyDefinition { }); } + private async getHierarchyDefinition(): Promise { + this._impl ??= (async () => { + const isDefinitionContainerSupported = await this._idsCache.getIsDefinitionContainerSupported(); + return createPredicateBasedHierarchyDefinition({ + classHierarchyInspector: this._iModelAccess, + hierarchy: { + rootNodes: async (requestProps: DefineRootHierarchyLevelProps) => + this.createDefinitionContainersAndCategoriesQuery({ ...requestProps, viewType: this._viewType }), + childNodes: [ + { + parentInstancesNodePredicate: "BisCore.Category", + definitions: async (requestProps: DefineInstanceNodeChildHierarchyLevelProps) => this.createSubcategoryQuery(requestProps), + }, + ...(isDefinitionContainerSupported + ? [ + { + parentInstancesNodePredicate: DEFINITION_CONTAINER_CLASS, + definitions: async (requestProps: DefineInstanceNodeChildHierarchyLevelProps) => + this.createDefinitionContainersAndCategoriesQuery({ + ...requestProps, + viewType: this._viewType, + }), + }, + ] + : []), + ], + }, + }); + })(); + return this._impl; + } + public async defineHierarchyLevel(props: DefineHierarchyLevelProps) { - return this._impl.defineHierarchyLevel(props); + return (await this.getHierarchyDefinition()).defineHierarchyLevel(props); } - private async createRootHierarchyLevelDefinition(props: DefineRootHierarchyLevelProps & { viewType: "2d" | "3d" }): Promise { - const { categoryClass, categoryElementClass } = getClassesByView(props.viewType); - const instanceFilterClauses = await this._selectQueryFactory.createFilterClauses({ - filter: props.instanceFilter, - contentClass: { fullName: categoryClass, alias: "this" }, + private async createDefinitionContainersAndCategoriesQuery(props: { + parentNodeInstanceIds?: Id64Array; + instanceFilter?: GenericInstanceFilter; + viewType: "2d" | "3d"; + }): Promise { + const { parentNodeInstanceIds, instanceFilter, viewType } = props; + const { definitionContainers, categories } = + parentNodeInstanceIds === undefined + ? await this._idsCache.getRootDefinitionContainersAndCategories() + : await this._idsCache.getDirectChildDefinitionContainersAndCategories(parentNodeInstanceIds); + if (categories.length === 0 && definitionContainers.length === 0) { + return []; + } + + const categoriesWithSingleChild = new Array(); + const categoriesWithMultipleChildren = new Array(); + categories.forEach((category) => { + if (category.childCount > 1) { + categoriesWithMultipleChildren.push(category.id); + } else { + categoriesWithSingleChild.push(category.id); + } }); - return [ - { - fullClassName: categoryClass, - query: { - ecsql: ` - SELECT + const dataToDetermineHasChildren = + categoriesWithSingleChild.length > categoriesWithMultipleChildren.length + ? { ids: categoriesWithMultipleChildren, ifTrue: 1, ifFalse: 0 } + : { ids: categoriesWithSingleChild, ifTrue: 0, ifFalse: 1 }; + + const { categoryClass } = getClassesByView(viewType); + + const [categoriesInstanceFilterClauses, definitionContainersInstanceFilterClauses] = await Promise.all( + [categoryClass, ...(definitionContainers.length > 0 ? [DEFINITION_CONTAINER_CLASS] : [])].map(async (className) => + this._selectQueryFactory.createFilterClauses({ + filter: instanceFilter, + contentClass: { fullName: className, alias: "this" }, + }), + ), + ); + + const definitionContainersQuery = + definitionContainers.length > 0 + ? ` + SELECT + ${await this._selectQueryFactory.createSelectClause({ + ecClassId: { selector: ECSql.createRawPropertyValueSelector("this", "ECClassId") }, + ecInstanceId: { selector: "this.ECInstanceId" }, + nodeLabel: { + selector: await this._nodeLabelSelectClauseFactory.createSelectClause({ + classAlias: "this", + className: DEFINITION_CONTAINER_CLASS, + }), + }, + extendedData: { + isDefinitionContainer: true, + imageId: "icon-definition-container", + }, + hasChildren: true, + supportsFiltering: true, + })} + FROM + ${definitionContainersInstanceFilterClauses.from} this + ${definitionContainersInstanceFilterClauses.joins} + WHERE + this.ECInstanceId IN (${definitionContainers.join(", ")}) + ${definitionContainersInstanceFilterClauses.where ? `AND ${definitionContainersInstanceFilterClauses.where}` : ""} + ` + : undefined; + + const categoriesQuery = + categories.length > 0 + ? ` + SELECT ${await this._selectQueryFactory.createSelectClause({ ecClassId: { selector: ECSql.createRawPropertyValueSelector("this", "ECClassId") }, ecInstanceId: { selector: "this.ECInstanceId" }, @@ -82,33 +178,39 @@ export class CategoriesTreeDefinition implements HierarchyDefinition { className: categoryClass, }), }, - hasChildren: { - selector: ` - IFNULL(( - SELECT 1 - FROM ( - SELECT COUNT(1) AS ChildCount - FROM BisCore.SubCategory sc - WHERE sc.Parent.Id = this.ECInstanceId - ) - WHERE ChildCount > 1 - ), 0) - `, - }, + ...(dataToDetermineHasChildren.ids.length > 0 + ? { + hasChildren: { + selector: ` + IIF(this.ECInstanceId IN (${dataToDetermineHasChildren.ids.join(",")}), + ${dataToDetermineHasChildren.ifTrue}, + ${dataToDetermineHasChildren.ifFalse} + ) + `, + }, + } + : { hasChildren: !!dataToDetermineHasChildren.ifFalse }), extendedData: { description: { selector: "this.Description" }, + isCategory: true, + imageId: "icon-layers", }, supportsFiltering: true, })} - FROM ${instanceFilterClauses.from} this - ${instanceFilterClauses.joins} - JOIN BisCore.Model m ON m.ECInstanceId = this.Model.Id + FROM + ${categoriesInstanceFilterClauses.from} this + ${categoriesInstanceFilterClauses.joins} WHERE - NOT this.IsPrivate - AND (NOT m.IsPrivate OR m.ECClassId IS (BisCore.DictionaryModel)) - AND EXISTS (SELECT 1 FROM ${categoryElementClass} e WHERE e.Category.Id = this.ECInstanceId) - ${instanceFilterClauses.where ? `AND ${instanceFilterClauses.where}` : ""} - `, + this.ECInstanceId IN (${categories.map((category) => category.id).join(", ")}) + ${categoriesInstanceFilterClauses.where ? `AND ${categoriesInstanceFilterClauses.where}` : ""} + ` + : undefined; + const queries = [categoriesQuery, definitionContainersQuery].filter((query) => query !== undefined); + return [ + { + fullClassName: DEFINITION_ELEMENT_CLASS, + query: { + ecsql: queries.join(" UNION ALL "), }, }, ]; @@ -120,11 +222,11 @@ export class CategoriesTreeDefinition implements HierarchyDefinition { }: DefineInstanceNodeChildHierarchyLevelProps): Promise { const instanceFilterClauses = await this._selectQueryFactory.createFilterClauses({ filter: instanceFilter, - contentClass: { fullName: "BisCore.SubCategory", alias: "this" }, + contentClass: { fullName: SUB_CATEGORY_CLASS, alias: "this" }, }); return [ { - fullClassName: "BisCore.SubCategory", + fullClassName: SUB_CATEGORY_CLASS, query: { ecsql: ` SELECT @@ -134,11 +236,13 @@ export class CategoriesTreeDefinition implements HierarchyDefinition { nodeLabel: { selector: await this._nodeLabelSelectClauseFactory.createSelectClause({ classAlias: "this", - className: "BisCore.SubCategory", + className: SUB_CATEGORY_CLASS, }), }, extendedData: { categoryId: { selector: "printf('0x%x', this.Parent.Id)" }, + isSubCategory: true, + imageId: "icon-layers-isolate", }, supportsFiltering: false, })} @@ -154,93 +258,160 @@ export class CategoriesTreeDefinition implements HierarchyDefinition { ]; } - public static async createInstanceKeyPaths(props: CategoriesTreeInstanceKeyPathsFromInstanceLabelProps) { + public static async createInstanceKeyPaths(props: CategoriesTreeInstanceKeyPathsFromInstanceLabelProps): Promise { const labelsFactory = createBisInstanceLabelSelectClauseFactory({ classHierarchyInspector: props.imodelAccess }); - return createInstanceKeyPathsFromInstanceLabel({ ...props, labelsFactory }); + return createInstanceKeyPathsFromInstanceLabel({ ...props, labelsFactory, cache: props.idsCache }); } } -function getClassesByView(viewType: "2d" | "3d") { - return viewType === "2d" - ? { categoryClass: "BisCore.DrawingCategory", categoryElementClass: "BisCore:GeometricElement2d" } - : { categoryClass: "BisCore.SpatialCategory", categoryElementClass: "BisCore:GeometricElement3d" }; -} - async function createInstanceKeyPathsFromInstanceLabel( - props: CategoriesTreeInstanceKeyPathsFromInstanceLabelProps & { labelsFactory: IInstanceLabelSelectClauseFactory }, -) { - const { categoryClass, categoryElementClass } = getClassesByView(props.viewType); + props: CategoriesTreeInstanceKeyPathsFromInstanceLabelProps & { labelsFactory: IInstanceLabelSelectClauseFactory; cache: CategoriesTreeIdsCache }, +): Promise { + const { definitionContainers, categories } = await props.cache.getAllDefinitionContainersAndCategories(); + if (categories.length === 0) { + return []; + } + + const { categoryClass } = getClassesByView(props.viewType); const adjustedLabel = props.label.replace(/[%_\\]/g, "\\$&"); - const reader = props.imodelAccess.createQueryReader( - { - ctes: [ - `RootCategoriesWithLabels(ClassName, ECInstanceId, ChildCount, DisplayLabel) as ( - SELECT - ec_classname(this.ECClassId, 's.c'), - this.ECInstanceId, - COUNT(sc.ECInstanceId), - ${await props.labelsFactory.createSelectClause({ - classAlias: "this", - className: categoryClass, - })} - FROM ${categoryClass} this - JOIN BisCore.Model m ON m.ECInstanceId = this.Model.Id - JOIN BisCore.SubCategory sc ON sc.Parent.Id = this.ECInstanceId - WHERE - NOT this.IsPrivate - AND (NOT m.IsPrivate OR m.ECClassId IS (BisCore.DictionaryModel)) - AND EXISTS (SELECT 1 FROM ${categoryElementClass} e WHERE e.Category.Id = this.ECInstanceId) - GROUP BY this.ECInstanceId - )`, - `SubCategoriesWithLabels(ClassName, ECInstanceId, ParentId, DisplayLabel) as ( - SELECT - ec_classname(this.ECClassId, 's.c'), - this.ECInstanceId, - this.Parent.Id, - ${await props.labelsFactory.createSelectClause({ - classAlias: "this", - className: "BisCore.SubCategory", - })} - FROM BisCore.SubCategory this - WHERE NOT this.IsPrivate - )`, - ], - ecsql: ` - SELECT * FROM ( - SELECT - c.ClassName AS CategoryClass, - c.ECInstanceId AS CategoryId, - sc.ClassName AS SubcategoryClass, - sc.ECInstanceId AS SubcategoryId - FROM RootCategoriesWithLabels c - JOIN SubCategoriesWithLabels sc ON sc.ParentId = c.ECInstanceId - WHERE c.ChildCount > 1 AND sc.DisplayLabel LIKE '%' || ? || '%' ESCAPE '\\' - UNION ALL - SELECT - c.ClassName AS CategoryClass, - c.ECInstanceId AS CategoryId, - CAST(NULL AS TEXT) AS SubcategoryClass, - CAST(NULL AS TEXT) AS SubcategoryId - FROM RootCategoriesWithLabels c - WHERE c.DisplayLabel LIKE '%' || ? || '%' ESCAPE '\\' + + const CATEGORIES_WITH_LABELS_CTE = "CategoriesWithLabels"; + const SUBCATEGORIES_WITH_LABELS_CTE = "SubCategoriesWithLabels"; + const DEFINITION_CONTAINERS_WITH_LABELS_CTE = "DefinitionContainersWithLabels"; + const [categoryLabelSelectClause, subCategoryLabelSelectClause, definitionContainerLabelSelectClause] = await Promise.all( + [categoryClass, SUB_CATEGORY_CLASS, ...(definitionContainers.length > 0 ? [DEFINITION_CONTAINER_CLASS] : [])].map(async (className) => + props.labelsFactory.createSelectClause({ classAlias: "this", className }), + ), + ); + return lastValueFrom( + defer(() => { + const ctes = [ + `${CATEGORIES_WITH_LABELS_CTE}(ClassName, ECInstanceId, ChildCount, DisplayLabel) AS ( + SELECT + 'c', + this.ECInstanceId, + COUNT(sc.ECInstanceId), + ${categoryLabelSelectClause} + FROM + ${categoryClass} this + JOIN ${SUB_CATEGORY_CLASS} sc ON sc.Parent.Id = this.ECInstanceId + WHERE + this.ECInstanceId IN (${categories.join(", ")}) + GROUP BY this.ECInstanceId + )`, + `${SUBCATEGORIES_WITH_LABELS_CTE}(ClassName, ECInstanceId, ParentId, DisplayLabel) AS ( + SELECT + 'sc', + this.ECInstanceId, + this.Parent.Id, + ${subCategoryLabelSelectClause} + FROM + ${SUB_CATEGORY_CLASS} this + WHERE + NOT this.IsPrivate + AND this.Parent.Id IN (${categories.join(", ")}) + )`, + ...(definitionContainers.length > 0 + ? [ + `${DEFINITION_CONTAINERS_WITH_LABELS_CTE}(ClassName, ECInstanceId, DisplayLabel) AS ( + SELECT + 'dc', + this.ECInstanceId, + ${definitionContainerLabelSelectClause} + FROM + ${DEFINITION_CONTAINER_CLASS} this + WHERE + this.ECInstanceId IN (${definitionContainers.join(", ")}) + )`, + ] + : []), + ]; + const ecsql = ` + SELECT * FROM ( + SELECT + sc.ClassName AS ClassName, + sc.ECInstanceId AS ECInstanceId + FROM + ${CATEGORIES_WITH_LABELS_CTE} c + JOIN ${SUBCATEGORIES_WITH_LABELS_CTE} sc ON sc.ParentId = c.ECInstanceId + WHERE + c.ChildCount > 1 + AND sc.DisplayLabel LIKE '%' || ? || '%' ESCAPE '\\' + + UNION ALL + + SELECT + c.ClassName AS ClassName, + c.ECInstanceId AS ECInstanceId + FROM + ${CATEGORIES_WITH_LABELS_CTE} c + WHERE + c.DisplayLabel LIKE '%' || ? || '%' ESCAPE '\\' + + ${ + definitionContainers.length > 0 + ? ` + UNION ALL + SELECT + dc.ClassName AS ClassName, + dc.ECInstanceId AS ECInstanceId + FROM + ${DEFINITION_CONTAINERS_WITH_LABELS_CTE} dc + WHERE + dc.DisplayLabel LIKE '%' || ? || '%' ESCAPE '\\' + ` + : "" + } ) - LIMIT ${MAX_FILTERING_INSTANCE_KEY_COUNT + 1} - `, - bindings: [ - { type: "string", value: adjustedLabel }, - { type: "string", value: adjustedLabel }, - ], - }, - { restartToken: "tree-widget/categories-tree/filter-by-label-query" }, + ${props.limit === undefined ? `LIMIT ${MAX_FILTERING_INSTANCE_KEY_COUNT + 1}` : props.limit !== "unbounded" ? `LIMIT ${props.limit}` : ""} + `; + const bindings = [ + { type: "string" as const, value: adjustedLabel }, + { type: "string" as const, value: adjustedLabel }, + ...(definitionContainers.length > 0 ? [{ type: "string" as const, value: adjustedLabel }] : []), + ]; + return props.imodelAccess.createQueryReader( + { ctes, ecsql, bindings }, + { restartToken: "tree-widget/categories-tree/filter-by-label-query", limit: props.limit }, + ); + }).pipe( + map( + (row): InstanceKey => ({ + className: row.ClassName === "c" ? categoryClass : row.ClassName === "sc" ? SUB_CATEGORY_CLASS : DEFINITION_CONTAINER_CLASS, + id: row.ECInstanceId, + }), + ), + toArray(), + mergeMap((targetItems): Observable => createInstanceKeyPathsFromTargetItems({ ...props, targetItems })), + toArray(), + ), ); - const paths: HierarchyFilteringPath[] = []; - for await (const row of reader) { - const path = { path: [{ className: row.CategoryClass, id: row.CategoryId }], options: { autoExpand: true } }; - row.SubcategoryId && path.path.push({ className: row.SubcategoryClass, id: row.SubcategoryId }); - paths.push(path); +} + +function createInstanceKeyPathsFromTargetItems( + props: Pick & { + targetItems: InstanceKey[]; + }, +): Observable { + const { limit, targetItems, viewType, idsCache } = props; + if (limit !== "unbounded" && targetItems.length > (limit ?? MAX_FILTERING_INSTANCE_KEY_COUNT)) { + throw new FilterLimitExceededError(limit ?? MAX_FILTERING_INSTANCE_KEY_COUNT); } - if (paths.length > MAX_FILTERING_INSTANCE_KEY_COUNT) { - throw new FilterLimitExceededError(MAX_FILTERING_INSTANCE_KEY_COUNT); + + if (targetItems.length === 0) { + return EMPTY; } - return paths; + + const { categoryClass } = getClassesByView(viewType); + return from(targetItems).pipe( + mergeMap(async (targetItem) => { + if (targetItem.className === SUB_CATEGORY_CLASS) { + return { path: await idsCache.getInstanceKeyPaths({ subCategoryId: targetItem.id }), options: { autoExpand: true } }; + } + if (targetItem.className === categoryClass) { + return { path: await idsCache.getInstanceKeyPaths({ categoryId: targetItem.id }), options: { autoExpand: true } }; + } + return { path: await idsCache.getInstanceKeyPaths({ definitionContainerId: targetItem.id }), options: { autoExpand: true } }; + }), + ); } diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesVisibilityHandler.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesVisibilityHandler.ts deleted file mode 100644 index 589758e300..0000000000 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesVisibilityHandler.ts +++ /dev/null @@ -1,116 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Bentley Systems, Incorporated. All rights reserved. - * See LICENSE.md in the project root for license terms and full copyright notice. - *--------------------------------------------------------------------------------------------*/ - -import { BeEvent } from "@itwin/core-bentley"; -import { HierarchyNode } from "@itwin/presentation-hierarchies"; -import { enableCategoryDisplay, enableSubCategoryDisplay } from "../common/CategoriesVisibilityUtils.js"; -import { createVisibilityStatus } from "../common/Tooltip.js"; - -import type { Viewport } from "@itwin/core-frontend"; -import type { HierarchyVisibilityHandler, VisibilityStatus } from "../common/UseHierarchyVisibility.js"; - -interface CategoriesVisibilityHandlerProps { - viewport: Viewport; -} - -/** @internal */ -export class CategoriesVisibilityHandler implements HierarchyVisibilityHandler { - private _pendingVisibilityChange: any; - private _viewport: Viewport; - - constructor(props: CategoriesVisibilityHandlerProps) { - this._viewport = props.viewport; - this._viewport.onDisplayStyleChanged.addListener(this.onDisplayStyleChanged); - this._viewport.onViewedCategoriesChanged.addListener(this.onViewedCategoriesChanged); - } - - public dispose() { - this._viewport.onDisplayStyleChanged.removeListener(this.onDisplayStyleChanged); - this._viewport.onViewedCategoriesChanged.removeListener(this.onViewedCategoriesChanged); - clearTimeout(this._pendingVisibilityChange); - } - - public onVisibilityChange = new BeEvent(); - - /** Returns visibility status of the tree node. */ - public getVisibilityStatus(node: HierarchyNode): Promise | VisibilityStatus { - if (!HierarchyNode.isInstancesNode(node)) { - return { state: "hidden", isDisabled: true }; - } - return createVisibilityStatus(node.parentKeys.length ? this.getSubCategoryVisibility(node) : this.getCategoryVisibility(node)); - } - - public async changeVisibility(node: HierarchyNode, on: boolean) { - if (!HierarchyNode.isInstancesNode(node)) { - return; - } - - // handle subcategory visibility change - if (node.parentKeys.length) { - const childId = CategoriesVisibilityHandler.getInstanceIdFromHierarchyNode(node); - const parentCategoryId = node.extendedData?.categoryId; - - // make sure parent category is enabled - if (on && parentCategoryId) { - await this.enableCategory([parentCategoryId], true, false); - } - - this.enableSubCategory(childId, on); - return; - } - - const instanceId = CategoriesVisibilityHandler.getInstanceIdFromHierarchyNode(node); - await this.enableCategory([instanceId], on, true); - } - - public getSubCategoryVisibility(node: HierarchyNode) { - const parentCategoryId = node.extendedData?.categoryId; - if (!parentCategoryId) { - return "hidden"; - } - - const subcategoryId = CategoriesVisibilityHandler.getInstanceIdFromHierarchyNode(node); - const isVisible = this._viewport.view.viewsCategory(parentCategoryId) && this._viewport.isSubCategoryVisible(subcategoryId); - return isVisible ? "visible" : "hidden"; - } - - public getCategoryVisibility(node: HierarchyNode) { - const instanceId = CategoriesVisibilityHandler.getInstanceIdFromHierarchyNode(node); - return this._viewport.view.viewsCategory(instanceId) ? "visible" : "hidden"; - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - private onDisplayStyleChanged = () => { - this.onVisibilityChangeInternal(); - }; - - // eslint-disable-next-line @typescript-eslint/naming-convention - private onViewedCategoriesChanged = () => { - this.onVisibilityChangeInternal(); - }; - - private onVisibilityChangeInternal() { - if (this._pendingVisibilityChange) { - return; - } - - this._pendingVisibilityChange = setTimeout(() => { - this.onVisibilityChange.raiseEvent(); - this._pendingVisibilityChange = undefined; - }, 0); - } - - public static getInstanceIdFromHierarchyNode(node: HierarchyNode) { - return HierarchyNode.isInstancesNode(node) && node.key.instanceKeys.length > 0 ? node.key.instanceKeys[0].id : ""; - } - - public async enableCategory(ids: string[], enabled: boolean, enableAllSubCategories = true) { - await enableCategoryDisplay(this._viewport, ids, enabled, enableAllSubCategories); - } - - public enableSubCategory(key: string, enabled: boolean) { - enableSubCategoryDisplay(this._viewport, key, enabled); - } -} diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/UseCategoriesTree.tsx b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/UseCategoriesTree.tsx index fb7702b818..1f1106e79f 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/UseCategoriesTree.tsx +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/UseCategoriesTree.tsx @@ -4,21 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import { useCallback, useMemo, useState } from "react"; +import { assert } from "@itwin/core-bentley"; import { SvgLayers } from "@itwin/itwinui-icons-react"; import { Text } from "@itwin/itwinui-react"; -import { HierarchyNodeIdentifier } from "@itwin/presentation-hierarchies"; +import { createECSqlQueryExecutor } from "@itwin/presentation-core-interop"; +import { HierarchyFilteringPath, HierarchyNodeIdentifier } from "@itwin/presentation-hierarchies"; import { TreeWidget } from "../../../TreeWidget.js"; import { FilterLimitExceededError } from "../common/TreeErrors.js"; import { useTelemetryContext } from "../common/UseTelemetryContext.js"; import { CategoriesTreeDefinition } from "./CategoriesTreeDefinition.js"; -import { CategoriesVisibilityHandler } from "./CategoriesVisibilityHandler.js"; +import { CategoriesTreeIdsCache } from "./internal/CategoriesTreeIdsCache.js"; +import { CategoriesVisibilityHandler } from "./internal/CategoriesVisibilityHandler.js"; +import { DEFINITION_CONTAINER_CLASS, SUB_CATEGORY_CLASS } from "./internal/ClassNameDefinitions.js"; +import type { Id64String } from "@itwin/core-bentley"; +import type { ReactElement } from "react"; import type { HierarchyNode } from "@itwin/presentation-hierarchies"; import type { VisibilityTreeProps } from "../common/components/VisibilityTree.js"; import type { Viewport } from "@itwin/core-frontend"; import type { PresentationHierarchyNode } from "@itwin/presentation-hierarchies-react"; import type { VisibilityTreeRendererProps } from "../common/components/VisibilityTreeRenderer.js"; -import type { Id64String } from "@itwin/core-bentley"; import type { CategoryInfo } from "../common/CategoriesVisibilityUtils.js"; type CategoriesTreeFilteringError = "tooManyFilterMatches" | "unknownFilterError"; @@ -46,9 +51,18 @@ interface UseCategoriesTreeResult { */ export function useCategoriesTree({ filter, activeView, onCategoriesFiltered }: UseCategoriesTreeProps): UseCategoriesTreeResult { const [filteringError, setFilteringError] = useState(); + + const viewType = activeView.view.is2d() ? "2d" : "3d"; + const iModel = activeView.iModel; + + const idsCache = useMemo(() => { + return new CategoriesTreeIdsCache(createECSqlQueryExecutor(iModel), viewType); + }, [viewType, iModel]); + const visibilityHandlerFactory = useCallback(() => { const visibilityHandler = new CategoriesVisibilityHandler({ viewport: activeView, + idsCache, }); return { getVisibilityStatus: async (node: HierarchyNode) => visibilityHandler.getVisibilityStatus(node), @@ -56,14 +70,14 @@ export function useCategoriesTree({ filter, activeView, onCategoriesFiltered }: onVisibilityChange: visibilityHandler.onVisibilityChange, dispose: () => visibilityHandler.dispose(), }; - }, [activeView]); + }, [activeView, idsCache]); const { onFeatureUsed } = useTelemetryContext(); const getHierarchyDefinition = useCallback( (props) => { - return new CategoriesTreeDefinition({ ...props, viewType: activeView.view.is2d() ? "2d" : "3d" }); + return new CategoriesTreeDefinition({ ...props, viewType, idsCache }); }, - [activeView], + [viewType, idsCache], ); const getFilteredPaths = useMemo(() => { @@ -75,8 +89,8 @@ export function useCategoriesTree({ filter, activeView, onCategoriesFiltered }: return async ({ imodelAccess }) => { onFeatureUsed({ featureId: "filtering", reportInteraction: true }); try { - const paths = await CategoriesTreeDefinition.createInstanceKeyPaths({ imodelAccess, label: filter, viewType: activeView.view.is2d() ? "2d" : "3d" }); - onCategoriesFiltered?.(getCategories(paths)); + const paths = await CategoriesTreeDefinition.createInstanceKeyPaths({ imodelAccess, label: filter, viewType, idsCache }); + onCategoriesFiltered?.(await getCategoriesFromPaths(paths, idsCache)); return paths; } catch (e) { const newError = e instanceof FilterLimitExceededError ? "tooManyFilterMatches" : "unknownFilterError"; @@ -88,7 +102,7 @@ export function useCategoriesTree({ filter, activeView, onCategoriesFiltered }: return []; } }; - }, [filter, activeView, onFeatureUsed, onCategoriesFiltered]); + }, [filter, viewType, onFeatureUsed, onCategoriesFiltered, idsCache]); return { categoriesTreeProps: { @@ -106,26 +120,55 @@ export function useCategoriesTree({ filter, activeView, onCategoriesFiltered }: }; } -function getCategories(paths: HierarchyFilteringPaths): CategoryInfo[] | undefined { +async function getCategoriesFromPaths(paths: HierarchyFilteringPaths, idsCache: CategoriesTreeIdsCache): Promise { if (!paths) { return undefined; } const categories = new Map(); for (const path of paths) { - const currPath = Array.isArray(path) ? path : path.path; - const [category, subCategory] = currPath; + const currPath = HierarchyFilteringPath.normalize(path).path; + if (currPath.length === 0) { + continue; + } - if (!HierarchyNodeIdentifier.isInstanceNodeIdentifier(category)) { + let category: HierarchyNodeIdentifier; + let subCategory: HierarchyNodeIdentifier | undefined; + const lastNode = currPath[currPath.length - 1]; + + if (!HierarchyNodeIdentifier.isInstanceNodeIdentifier(lastNode)) { continue; } - if (!categories.has(category.id)) { - categories.set(category.id, []); + if (lastNode.className === DEFINITION_CONTAINER_CLASS) { + const definitionContainerCategories = await idsCache.getAllContainedCategories([lastNode.id]); + for (const categoryId of definitionContainerCategories) { + const value = categories.get(categoryId); + if (value === undefined) { + categories.set(categoryId, []); + } + } + continue; + } + + if (lastNode.className === SUB_CATEGORY_CLASS) { + const secondToLastNode = currPath.length > 1 ? currPath[currPath.length - 2] : undefined; + assert(secondToLastNode !== undefined && HierarchyNodeIdentifier.isInstanceNodeIdentifier(secondToLastNode)); + + subCategory = lastNode; + category = secondToLastNode; + } else { + category = lastNode; + } + + let entry = categories.get(category.id); + if (entry === undefined) { + entry = []; + categories.set(category.id, entry); } - if (subCategory && HierarchyNodeIdentifier.isInstanceNodeIdentifier(subCategory)) { - categories.get(category.id)!.push(subCategory.id); + if (subCategory) { + entry.push(subCategory.id); } } @@ -145,8 +188,53 @@ function getNoDataMessage(filter: string, error?: CategoriesTreeFilteringError) return undefined; } -function getIcon() { - return ; +function SvgLayersIsolate() { + return ( + + + + + + + + + + + + + + + ); +} + +function SvgBisDefinitionContainer() { + return ( + + + + ); +} + +function getIcon(node: PresentationHierarchyNode): ReactElement | undefined { + if (node.extendedData?.imageId === undefined) { + return undefined; + } + + switch (node.extendedData.imageId) { + case "icon-layers": + return ; + case "icon-layers-isolate": + return ; + case "icon-definition-container": + return ; + } + + return undefined; } function getSublabel(node: PresentationHierarchyNode) { diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.ts new file mode 100644 index 0000000000..9267139043 --- /dev/null +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.ts @@ -0,0 +1,363 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { DEFINITION_CONTAINER_CLASS, SUB_CATEGORY_CLASS } from "./ClassNameDefinitions.js"; + +import type { Id64Array, Id64String } from "@itwin/core-bentley"; +import type { LimitingECSqlQueryExecutor } from "@itwin/presentation-hierarchies"; +import type { InstanceKey } from "@itwin/presentation-shared"; + +interface DefinitionContainerInfo { + modelId: Id64String; + parentDefinitionContainerExists: boolean; + childCategories: CategoryInfo[]; + childDefinitionContainers: Id64Array; +} + +interface CategoriesInfo { + childCategories: CategoryInfo[]; + parentDefinitionContainerExists: boolean; +} + +interface CategoryInfo { + id: Id64String; + childCount: number; +} + +interface SubCategoryInfo { + categoryId: Id64String; +} + +/** @internal */ +export class CategoriesTreeIdsCache { + private _definitionContainersInfo: Promise> | undefined; + private _modelsCategoriesInfo: Promise> | undefined; + private _subCategoriesInfo: Promise> | undefined; + private _categoryClass: string; + private _categoryElementClass: string; + private _isDefinitionContainerSupported: Promise | undefined; + + constructor( + private _queryExecutor: LimitingECSqlQueryExecutor, + viewType: "3d" | "2d", + ) { + const { categoryClass, categoryElementClass } = getClassesByView(viewType); + this._categoryClass = categoryClass; + this._categoryElementClass = categoryElementClass; + } + + private async *queryCategories(): AsyncIterableIterator<{ + id: Id64String; + modelId: Id64String; + parentDefinitionContainerExists: boolean; + childCount: number; + }> { + const isDefinitionContainerSupported = await this.getIsDefinitionContainerSupported(); + const categoriesQuery = ` + SELECT + this.ECInstanceId id, + COUNT(sc.ECInstanceId) childCount, + this.Model.Id modelId, + ${ + isDefinitionContainerSupported + ? ` + IIF(this.Model.Id IN (SELECT dc.ECInstanceId FROM ${DEFINITION_CONTAINER_CLASS} dc), + true, + false + )` + : "false" + } parentDefinitionContainerExists + FROM + ${this._categoryClass} this + JOIN ${SUB_CATEGORY_CLASS} sc ON sc.Parent.Id = this.ECInstanceId + JOIN BisCore.Model m ON m.ECInstanceId = this.Model.Id + WHERE + NOT this.IsPrivate + AND (NOT m.IsPrivate OR m.ECClassId IS (BisCore.DictionaryModel)) + AND EXISTS (SELECT 1 FROM ${this._categoryElementClass} e WHERE e.Category.Id = this.ECInstanceId) + GROUP BY this.ECInstanceId + `; + for await (const row of this._queryExecutor.createQueryReader({ ecsql: categoriesQuery }, { rowFormat: "ECSqlPropertyNames", limit: "unbounded" })) { + yield { id: row.id, modelId: row.modelId, parentDefinitionContainerExists: row.parentDefinitionContainerExists, childCount: row.childCount }; + } + } + + private async queryIsDefinitionContainersSupported(): Promise { + const query = ` + SELECT + 1 + FROM + ECDbMeta.ECSchemaDef s + JOIN ECDbMeta.ECClassDef c ON c.Schema.Id = s.ECInstanceId + WHERE + s.Name = 'BisCore' + AND c.Name = 'DefinitionContainer' + `; + + for await (const _row of this._queryExecutor.createQueryReader({ ecsql: query })) { + return true; + } + return false; + } + + private async *queryDefinitionContainers(): AsyncIterableIterator<{ id: Id64String; modelId: Id64String }> { + // DefinitionModel ECInstanceId will always be the same as modeled DefinitionContainer ECInstanceId, if this wasn't the case, we would need to do something like: + // JOIN BisCore.DefinitionModel dm ON dm.ECInstanceId = ${modelIdAccessor} + // JOIN BisCore.DefinitionModelBreaksDownDefinitionContainer dr ON dr.SourceECInstanceId = dm.ECInstanceId + // JOIN BisCore.DefinitionContainer dc ON dc.ECInstanceId = dr.TargetECInstanceId + const DEFINITION_CONTAINERS_CTE = "DefinitionContainers"; + const ctes = [ + ` + ${DEFINITION_CONTAINERS_CTE}(ECInstanceId, ModelId) AS ( + SELECT + dc.ECInstanceId, + dc.Model.Id + FROM + ${DEFINITION_CONTAINER_CLASS} dc + WHERE + dc.ECInstanceId IN (SELECT c.Model.Id FROM ${this._categoryClass} c WHERE NOT c.IsPrivate) + AND NOT dc.IsPrivate + + UNION ALL + + SELECT + pdc.ECInstanceId, + pdc.Model.Id + FROM + ${DEFINITION_CONTAINERS_CTE} cdc + JOIN ${DEFINITION_CONTAINER_CLASS} pdc ON pdc.ECInstanceId = cdc.ModelId + WHERE + NOT pdc.IsPrivate + ) + `, + ]; + const definitionsQuery = ` + SELECT dc.ECInstanceId id, dc.ModelId modelId FROM ${DEFINITION_CONTAINERS_CTE} dc GROUP BY dc.ECInstanceId + `; + for await (const row of this._queryExecutor.createQueryReader({ ctes, ecsql: definitionsQuery }, { rowFormat: "ECSqlPropertyNames", limit: "unbounded" })) { + yield { id: row.id, modelId: row.modelId }; + } + } + + private async *queryVisibleSubCategories(categoriesInfo: Id64Array): AsyncIterableIterator<{ id: Id64String; parentId: Id64String }> { + const definitionsQuery = ` + SELECT + sc.ECInstanceId id, + sc.Parent.Id categoryId + FROM + ${SUB_CATEGORY_CLASS} sc + WHERE + NOT sc.IsPrivate + AND sc.Parent.Id IN (${categoriesInfo.join(",")}) + `; + for await (const row of this._queryExecutor.createQueryReader({ ecsql: definitionsQuery }, { rowFormat: "ECSqlPropertyNames", limit: "unbounded" })) { + yield { id: row.id, parentId: row.categoryId }; + } + } + + private async getModelsCategoriesInfo() { + this._modelsCategoriesInfo ??= (async () => { + const allModelsCategories = new Map(); + for await (const queriedCategory of this.queryCategories()) { + let modelCategories = allModelsCategories.get(queriedCategory.modelId); + if (modelCategories === undefined) { + modelCategories = { parentDefinitionContainerExists: queriedCategory.parentDefinitionContainerExists, childCategories: [] }; + allModelsCategories.set(queriedCategory.modelId, modelCategories); + } + modelCategories.childCategories.push({ id: queriedCategory.id, childCount: queriedCategory.childCount }); + } + return allModelsCategories; + })(); + return this._modelsCategoriesInfo; + } + + private async getSubCategoriesInfo() { + this._subCategoriesInfo ??= (async () => { + const allSubCategories = new Map(); + const modelsCategoriesInfo = await this.getModelsCategoriesInfo(); + const categoriesWithMoreThanOneSubCategory = new Array(); + for (const modelCategoriesInfo of modelsCategoriesInfo.values()) { + categoriesWithMoreThanOneSubCategory.push( + ...modelCategoriesInfo.childCategories.filter((categoryInfo) => categoryInfo.childCount > 1).map((categoryInfo) => categoryInfo.id), + ); + } + + if (categoriesWithMoreThanOneSubCategory.length === 0) { + return allSubCategories; + } + + for await (const queriedSubCategory of this.queryVisibleSubCategories(categoriesWithMoreThanOneSubCategory)) { + allSubCategories.set(queriedSubCategory.id, { categoryId: queriedSubCategory.parentId }); + } + return allSubCategories; + })(); + return this._subCategoriesInfo; + } + + private async getDefinitionContainersInfo() { + this._definitionContainersInfo ??= (async () => { + const definitionContainersInfo = new Map(); + const [isDefinitionContainerSupported, modelsCategoriesInfo] = await Promise.all([ + this.getIsDefinitionContainerSupported(), + this.getModelsCategoriesInfo(), + ]); + if (!isDefinitionContainerSupported || modelsCategoriesInfo.size === 0) { + return definitionContainersInfo; + } + + for await (const queriedDefinitionContainer of this.queryDefinitionContainers()) { + const modelCategoriesInfo = modelsCategoriesInfo.get(queriedDefinitionContainer.id); + + definitionContainersInfo.set(queriedDefinitionContainer.id, { + childCategories: modelCategoriesInfo?.childCategories ?? [], + modelId: queriedDefinitionContainer.modelId, + childDefinitionContainers: [], + parentDefinitionContainerExists: false, + }); + } + + for (const [definitionContainerId, definitionContainerInfo] of definitionContainersInfo) { + const parentDefinitionContainer = definitionContainersInfo.get(definitionContainerInfo.modelId); + if (parentDefinitionContainer !== undefined) { + parentDefinitionContainer.childDefinitionContainers.push(definitionContainerId); + definitionContainerInfo.parentDefinitionContainerExists = true; + } + } + + return definitionContainersInfo; + })(); + return this._definitionContainersInfo; + } + + public async getDirectChildDefinitionContainersAndCategories( + parentDefinitionContainerIds: Id64Array, + ): Promise<{ categories: CategoryInfo[]; definitionContainers: Id64Array }> { + const definitionContainersInfo = await this.getDefinitionContainersInfo(); + + const result = { definitionContainers: new Array(), categories: new Array() }; + + parentDefinitionContainerIds.forEach((parentDefinitionContainerId) => { + const parentDefinitionContainerInfo = definitionContainersInfo.get(parentDefinitionContainerId); + if (parentDefinitionContainerInfo !== undefined) { + result.definitionContainers.push(...parentDefinitionContainerInfo.childDefinitionContainers); + result.categories.push(...parentDefinitionContainerInfo.childCategories); + } + }); + return result; + } + + public async getAllContainedCategories(definitionContainerIds: Id64Array): Promise { + const result = new Array(); + + const definitionContainersInfo = await this.getDefinitionContainersInfo(); + const indirectCategories = await Promise.all( + definitionContainerIds.map(async (definitionContainerId) => { + const definitionContainerInfo = definitionContainersInfo.get(definitionContainerId); + if (definitionContainerInfo === undefined) { + return []; + } + result.push(...definitionContainerInfo.childCategories.map((category) => category.id)); + return this.getAllContainedCategories(definitionContainerInfo.childDefinitionContainers); + }), + ); + for (const categories of indirectCategories) { + result.push(...categories); + } + + return result; + } + + public async getInstanceKeyPaths( + props: { categoryId: Id64String } | { definitionContainerId: Id64String } | { subCategoryId: Id64String }, + ): Promise { + if ("subCategoryId" in props) { + const subCategoriesInfo = await this.getSubCategoriesInfo(); + const subCategoryInfo = subCategoriesInfo.get(props.subCategoryId); + if (subCategoryInfo === undefined) { + return []; + } + return [...(await this.getInstanceKeyPaths({ categoryId: subCategoryInfo.categoryId })), { id: props.subCategoryId, className: SUB_CATEGORY_CLASS }]; + } + + if ("categoryId" in props) { + const modelsCategoriesInfo = await this.getModelsCategoriesInfo(); + for (const [modelId, modelCategoriesInfo] of modelsCategoriesInfo) { + if (modelCategoriesInfo.childCategories.find((childCategory) => childCategory.id === props.categoryId)) { + if (!modelCategoriesInfo.parentDefinitionContainerExists) { + return [{ id: props.categoryId, className: this._categoryClass }]; + } + + return [...(await this.getInstanceKeyPaths({ definitionContainerId: modelId })), { id: props.categoryId, className: this._categoryClass }]; + } + } + return []; + } + + const definitionContainersInfo = await this.getDefinitionContainersInfo(); + const definitionContainerInfo = definitionContainersInfo.get(props.definitionContainerId); + if (definitionContainerInfo === undefined) { + return []; + } + + if (!definitionContainerInfo.parentDefinitionContainerExists) { + return [{ id: props.definitionContainerId, className: DEFINITION_CONTAINER_CLASS }]; + } + + return [ + ...(await this.getInstanceKeyPaths({ definitionContainerId: definitionContainerInfo.modelId })), + { id: props.definitionContainerId, className: DEFINITION_CONTAINER_CLASS }, + ]; + } + + public async getAllDefinitionContainersAndCategories(): Promise<{ categories: Id64Array; definitionContainers: Id64Array }> { + const [modelsCategoriesInfo, definitionContainersInfo] = await Promise.all([this.getModelsCategoriesInfo(), this.getDefinitionContainersInfo()]); + const result = { definitionContainers: [...definitionContainersInfo.keys()], categories: new Array() }; + for (const modelCategoriesInfo of modelsCategoriesInfo.values()) { + result.categories.push(...modelCategoriesInfo.childCategories.map((childCategory) => childCategory.id)); + } + + return result; + } + + public async getRootDefinitionContainersAndCategories(): Promise<{ categories: CategoryInfo[]; definitionContainers: Id64Array }> { + const [modelsCategoriesInfo, definitionContainersInfo] = await Promise.all([this.getModelsCategoriesInfo(), this.getDefinitionContainersInfo()]); + const result = { definitionContainers: new Array(), categories: new Array() }; + for (const modelCategoriesInfo of modelsCategoriesInfo.values()) { + if (!modelCategoriesInfo.parentDefinitionContainerExists) { + result.categories.push(...modelCategoriesInfo.childCategories); + } + } + + for (const [definitionContainerId, definitionContainerInfo] of definitionContainersInfo) { + if (!definitionContainerInfo.parentDefinitionContainerExists) { + result.definitionContainers.push(definitionContainerId); + } + } + return result; + } + + public async getSubCategories(categoryId: Id64String): Promise { + const subCategoriesInfo = await this.getSubCategoriesInfo(); + const result = new Array(); + for (const [subCategoryId, subCategoryInfo] of subCategoriesInfo) { + if (subCategoryInfo.categoryId === categoryId) { + result.push(subCategoryId); + } + } + return result; + } + + public async getIsDefinitionContainerSupported(): Promise { + this._isDefinitionContainerSupported ??= this.queryIsDefinitionContainersSupported(); + return this._isDefinitionContainerSupported; + } +} + +/** @internal */ +export function getClassesByView(viewType: "2d" | "3d") { + return viewType === "2d" + ? { categoryClass: "BisCore.DrawingCategory", categoryElementClass: "BisCore.GeometricElement2d" } + : { categoryClass: "BisCore.SpatialCategory", categoryElementClass: "BisCore.GeometricElement3d" }; +} diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeNode.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeNode.ts new file mode 100644 index 0000000000..c8279c94ff --- /dev/null +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeNode.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import type { HierarchyNodeKey } from "@itwin/presentation-hierarchies"; + +interface CategoriesTreeNode { + key: HierarchyNodeKey; + extendedData?: { [id: string]: any }; +} + +/** + * @internal + */ +export namespace CategoriesTreeNode { + /** + * Determines if node represents a definition container. + */ + export const isDefinitionContainerNode = (node: Pick) => + node.extendedData && "isDefinitionContainer" in node.extendedData && !!node.extendedData.isDefinitionContainer; + + /** + * Determines if node represents a category. + */ + export const isCategoryNode = (node: Pick) => + node.extendedData && "isCategory" in node.extendedData && !!node.extendedData.isCategory; + + /** + * Determines if node represents a sub-category. + */ + export const isSubCategoryNode = (node: Pick) => + node.extendedData && "isSubCategory" in node.extendedData && !!node.extendedData.isSubCategory; +} diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesVisibilityHandler.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesVisibilityHandler.ts new file mode 100644 index 0000000000..cbf5d40fb1 --- /dev/null +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesVisibilityHandler.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { BeEvent } from "@itwin/core-bentley"; +import { HierarchyNode } from "@itwin/presentation-hierarchies"; +import { enableCategoryDisplay, enableSubCategoryDisplay } from "../../common/CategoriesVisibilityUtils.js"; +import { createVisibilityStatus } from "../../common/Tooltip.js"; +import { CategoriesTreeNode } from "./CategoriesTreeNode.js"; + +import type { Id64Array } from "@itwin/core-bentley"; +import type { Viewport } from "@itwin/core-frontend"; +import type { HierarchyVisibilityHandler, VisibilityStatus } from "../../common/UseHierarchyVisibility.js"; +import type { CategoriesTreeIdsCache } from "./CategoriesTreeIdsCache.js"; + +/** @internal */ +export interface CategoriesVisibilityHandlerProps { + viewport: Viewport; + idsCache: CategoriesTreeIdsCache; +} + +/** @internal */ +export class CategoriesVisibilityHandler implements HierarchyVisibilityHandler { + private _pendingVisibilityChange: any; + private _viewport: Viewport; + private _idsCache: CategoriesTreeIdsCache; + + constructor(props: CategoriesVisibilityHandlerProps) { + this._idsCache = props.idsCache; + this._viewport = props.viewport; + this._viewport.onDisplayStyleChanged.addListener(this.onDisplayStyleChanged); + this._viewport.onViewedCategoriesChanged.addListener(this.onViewedCategoriesChanged); + } + + public dispose() { + this[Symbol.dispose](); + } + + public [Symbol.dispose]() { + this._viewport.onDisplayStyleChanged.removeListener(this.onDisplayStyleChanged); + this._viewport.onViewedCategoriesChanged.removeListener(this.onViewedCategoriesChanged); + clearTimeout(this._pendingVisibilityChange); + } + + public onVisibilityChange = new BeEvent(); + + /** Returns visibility status of the tree node. */ + public async getVisibilityStatus(node: HierarchyNode): Promise { + if (!HierarchyNode.isInstancesNode(node)) { + return { state: "hidden", isDisabled: true }; + } + + if (CategoriesTreeNode.isSubCategoryNode(node)) { + return createVisibilityStatus(this.getSubCategoryVisibility(node)); + } + + if (CategoriesTreeNode.isCategoryNode(node)) { + return createVisibilityStatus(await this.getCategoriesVisibility(CategoriesVisibilityHandler.getInstanceIdsFromHierarchyNode(node))); + } + + if (CategoriesTreeNode.isDefinitionContainerNode(node)) { + return createVisibilityStatus(await this.getDefinitionContainerVisibility(node)); + } + + return { state: "hidden", isDisabled: true }; + } + + public async changeVisibility(node: HierarchyNode, on: boolean) { + if (!HierarchyNode.isInstancesNode(node)) { + return; + } + + if (CategoriesTreeNode.isCategoryNode(node)) { + return this.changeCategoryVisibility(node, on); + } + + if (CategoriesTreeNode.isSubCategoryNode(node)) { + return this.changeSubCategoryVisibility(node, on); + } + + if (CategoriesTreeNode.isDefinitionContainerNode(node)) { + return this.changeDefinitionContainerVisibility(node, on); + } + } + + private getSubCategoryVisibility(node: HierarchyNode): VisibilityStatus["state"] { + const parentCategoryId = node.extendedData?.categoryId; + if (!parentCategoryId) { + return "hidden"; + } + + const categoryOverrideResult = this.getCategoryVisibilityFromOverrides(parentCategoryId); + if (categoryOverrideResult === "hidden" || categoryOverrideResult === "visible") { + return categoryOverrideResult; + } + + if (!this._viewport.view.viewsCategory(parentCategoryId)) { + return "hidden"; + } + const subCategoryIds = CategoriesVisibilityHandler.getInstanceIdsFromHierarchyNode(node); + let visibleCount = 0; + let hiddenCount = 0; + for (const subCategoryId of subCategoryIds) { + const isVisible = this._viewport.isSubCategoryVisible(subCategoryId); + if (isVisible) { + ++visibleCount; + } else { + ++hiddenCount; + } + if (visibleCount > 0 && hiddenCount > 0) { + return "partial"; + } + } + return visibleCount > 0 ? "visible" : "hidden"; + } + + private async getDefinitionContainerVisibility(node: HierarchyNode): Promise { + const childrenResult = await this._idsCache.getAllContainedCategories(CategoriesVisibilityHandler.getInstanceIdsFromHierarchyNode(node)); + let hiddenCount = 0; + let visibleCount = 0; + for (const categoryId of childrenResult) { + const categoryVisibility = await this.getCategoriesVisibility([categoryId]); + if (categoryVisibility === "partial") { + return "partial"; + } + + if (categoryVisibility === "hidden") { + ++hiddenCount; + } else { + ++visibleCount; + } + + if (hiddenCount > 0 && visibleCount > 0) { + return "partial"; + } + } + + return hiddenCount > 0 ? "hidden" : "visible"; + } + + private async getCategoriesVisibility(categoryIds: Id64Array): Promise { + const overrideResult = this.getCategoryVisibilityFromOverrides(categoryIds); + if (overrideResult !== "none") { + return overrideResult; + } + let visibleCount = 0; + let hiddenCount = 0; + for (const categoryId of categoryIds) { + const isVisible = this._viewport.view.viewsCategory(categoryId); + if (isVisible) { + ++visibleCount; + } else { + ++hiddenCount; + } + if (visibleCount > 0 && hiddenCount > 0) { + return "partial"; + } + } + + if (hiddenCount > 0) { + return "hidden"; + } + + const subCategories = (await Promise.all(categoryIds.map(async (id) => this._idsCache.getSubCategories(id)))).reduce((acc, val) => acc.concat(val), []); + let visibleSubCategoryCount = 0; + let hiddenSubCategoryCount = 0; + + for (const subCategory of subCategories) { + const isVisible = this._viewport.isSubCategoryVisible(subCategory); + if (isVisible) { + ++visibleSubCategoryCount; + } else { + ++hiddenSubCategoryCount; + } + if (hiddenSubCategoryCount > 0 && visibleSubCategoryCount > 0) { + return "partial"; + } + } + + return hiddenSubCategoryCount > 0 ? "hidden" : "visible"; + } + + private getCategoryVisibilityFromOverrides(categoryIds: Id64Array): VisibilityStatus["state"] | "none" { + let showOverrides = 0; + let hideOverrides = 0; + + for (const currentOverride of this._viewport.perModelCategoryVisibility) { + if (categoryIds.includes(currentOverride.categoryId)) { + if (currentOverride.visible) { + ++showOverrides; + } else { + ++hideOverrides; + } + + if (showOverrides > 0 && hideOverrides > 0) { + return "partial"; + } + } + } + + if (showOverrides === 0 && hideOverrides === 0) { + return "none"; + } + + return showOverrides > 0 ? "visible" : "hidden"; + } + + private async changeSubCategoryVisibility(node: HierarchyNode, on: boolean) { + const parentCategoryId = node.extendedData?.categoryId; + + // make sure parent category is enabled + if (on && parentCategoryId) { + await this.changeCategoryState([parentCategoryId], true, false); + } + + const subCategoryIds = CategoriesVisibilityHandler.getInstanceIdsFromHierarchyNode(node); + subCategoryIds.forEach((id) => { + this.changeSubCategoryState(id, on); + }); + } + + private async changeCategoryVisibility(node: HierarchyNode, on: boolean) { + const categoryIds = CategoriesVisibilityHandler.getInstanceIdsFromHierarchyNode(node); + return this.changeCategoryState(categoryIds, on, on); + } + + private async changeDefinitionContainerVisibility(node: HierarchyNode, on: boolean) { + const definitionContainerId = CategoriesVisibilityHandler.getInstanceIdsFromHierarchyNode(node); + const childCategories = await this._idsCache.getAllContainedCategories(definitionContainerId); + return this.changeCategoryState(childCategories, on, on); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + private onDisplayStyleChanged = () => { + this.onVisibilityChangeInternal(); + }; + + // eslint-disable-next-line @typescript-eslint/naming-convention + private onViewedCategoriesChanged = () => { + this.onVisibilityChangeInternal(); + }; + + private onVisibilityChangeInternal() { + if (this._pendingVisibilityChange) { + return; + } + + this._pendingVisibilityChange = setTimeout(() => { + this.onVisibilityChange.raiseEvent(); + this._pendingVisibilityChange = undefined; + }, 0); + } + + private static getInstanceIdsFromHierarchyNode(node: HierarchyNode) { + return HierarchyNode.isInstancesNode(node) ? node.key.instanceKeys.map((instanceKey) => instanceKey.id) : /* istanbul ignore next */ []; + } + + private async changeCategoryState(ids: string[], enabled: boolean, enableAllSubCategories: boolean) { + await enableCategoryDisplay(this._viewport, ids, enabled, enableAllSubCategories); + } + + private changeSubCategoryState(key: string, enabled: boolean) { + enableSubCategoryDisplay(this._viewport, key, enabled); + } +} diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/ClassNameDefinitions.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/ClassNameDefinitions.ts new file mode 100644 index 0000000000..e5d720e9fe --- /dev/null +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/ClassNameDefinitions.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +/** @internal */ +export const SUB_CATEGORY_CLASS = "BisCore.SubCategory"; + +/** @internal */ +export const DEFINITION_CONTAINER_CLASS = "BisCore.DefinitionContainer"; + +/** @internal */ +export const DEFINITION_ELEMENT_CLASS = "BisCore.DefinitionElement"; diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/CategoriesVisibilityUtils.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/CategoriesVisibilityUtils.ts index 766e3a6b5b..b311d45964 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/CategoriesVisibilityUtils.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/CategoriesVisibilityUtils.ts @@ -32,7 +32,7 @@ export async function toggleAllCategories(viewport: Viewport, display: boolean) /** * Gets ids of all categories from specified imodel and viewport. */ -export async function getCategories(viewport: Viewport) { +async function getCategories(viewport: Viewport) { const categories = await loadCategoriesFromViewport(viewport); return categories.map((category) => category.categoryId); }