diff --git a/change/@itwin-property-grid-react-db3b1c25-9efb-4424-b503-6d4ed85398ba.json b/change/@itwin-property-grid-react-db3b1c25-9efb-4424-b503-6d4ed85398ba.json new file mode 100644 index 000000000..476d2eda3 --- /dev/null +++ b/change/@itwin-property-grid-react-db3b1c25-9efb-4424-b503-6d4ed85398ba.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "", + "packageName": "@itwin/property-grid-react", + "email": "100586436+JonasDov@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@itwin-tree-widget-react-c3197d23-0763-4d55-a452-0716c6140208.json b/change/@itwin-tree-widget-react-c3197d23-0763-4d55-a452-0716c6140208.json new file mode 100644 index 000000000..5c9a6107f --- /dev/null +++ b/change/@itwin-tree-widget-react-c3197d23-0763-4d55-a452-0716c6140208.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Changed the Models and Categories tree header buttons to behave more consistently. Fixed an issue where the 2D toggle was always disabled.", + "packageName": "@itwin/tree-widget-react", + "email": "100586436+JonasDov@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/tree-widget/src/e2e-tests/CategoriesTree.test.ts b/packages/tree-widget/src/e2e-tests/CategoriesTree.test.ts index 1bac2a096..1980bee3f 100644 --- a/packages/tree-widget/src/e2e-tests/CategoriesTree.test.ts +++ b/packages/tree-widget/src/e2e-tests/CategoriesTree.test.ts @@ -159,7 +159,7 @@ test.describe("Categories tree", () => { const node = locateNode(treeWidget, "Equipment"); await node.waitFor({ state: "visible" }); await node.getByRole("button", { name: "Determining visibility..." }).waitFor({ state: "detached" }); - await node.getByRole("button", { name: "Show" }).waitFor({ state: "attached" }); + await node.getByRole("button", { name: "Hide", includeHidden: true }).waitFor({ state: "attached" }); await takeScreenshot(page, treeWidget); }); }); diff --git a/packages/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-hides-all-categories-in-filtered-tree-1-chromium-linux.png b/packages/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-hides-all-categories-in-filtered-tree-1-chromium-linux.png index 50cc68a04..256ff3160 100644 Binary files a/packages/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-hides-all-categories-in-filtered-tree-1-chromium-linux.png and b/packages/tree-widget/src/e2e-tests/CategoriesTree.test.ts-snapshots/Categories-tree-hides-all-categories-in-filtered-tree-1-chromium-linux.png differ diff --git a/packages/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-search---not-found-1-chromium-linux.png b/packages/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-search---not-found-1-chromium-linux.png index 2caa6b3a6..ac2aa85a2 100644 Binary files a/packages/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-search---not-found-1-chromium-linux.png and b/packages/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-search---not-found-1-chromium-linux.png differ diff --git a/packages/tree-widget/src/test/trees/common/internal/VisibilityUtils.test.ts b/packages/tree-widget/src/test/trees/common/internal/VisibilityUtils.test.ts index e8dab314d..14c30fb5b 100644 --- a/packages/tree-widget/src/test/trees/common/internal/VisibilityUtils.test.ts +++ b/packages/tree-widget/src/test/trees/common/internal/VisibilityUtils.test.ts @@ -3,6 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ +import { EMPTY, firstValueFrom, from, toArray } from "rxjs"; import { HierarchyCacheMode, initializeCore, @@ -19,7 +20,17 @@ import { IModelApp, NoRenderApp } from "@itwin/core-frontend"; import { ECSchemaRpcInterface } from "@itwin/ecschema-rpcinterface-common"; import { ECSchemaRpcImpl } from "@itwin/ecschema-rpcinterface-impl"; import { PresentationRpcInterface } from "@itwin/presentation-common"; -import { enableCategoryDisplay, invertAllCategories } from "../../../../tree-widget-react/components/trees/common/internal/VisibilityUtils.js"; +import { createVisibilityStatus } from "../../../../tree-widget-react/components/trees/common/internal/Tooltip.js"; +import { + changeCategoryDisplay, + changeElementStateNoChildrenOperator, + getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl, + hideAllCategories, + invertAllCategories, + invertAllModels, + mergeVisibilityStatuses, + showAll, +} from "../../../../tree-widget-react/components/trees/common/internal/VisibilityUtils.js"; import { buildIModel } from "../../../IModelUtils.js"; import { TestUtils } from "../../../TestUtils.js"; import { createFakeViewport } from "../../Common.js"; @@ -71,34 +82,271 @@ describe("VisibilityUtils", () => { }); }); - describe("enableCategoryDisplay", () => { - it("enables category", async () => { - await enableCategoryDisplay(viewport, categoryId, true, false); + describe("changeCategoryDisplay", () => { + it("turns on category", () => { + changeCategoryDisplay({ viewport, categoryInfos: new Map([[categoryId, [subCategoryId]]]), display: true }); expect(viewport.changeCategoryDisplay).toHaveBeenCalledWith({ categoryIds: [categoryId], display: true, enableAllSubCategories: false }); + expect(viewport.changeSubCategoryDisplay).toHaveBeenCalledWith({ subCategoryId, display: true }); }); - it("disables category", async () => { - await enableCategoryDisplay(viewport, categoryId, false, false); + it("disables category", () => { + changeCategoryDisplay({ viewport, categoryInfos: new Map([[categoryId, [subCategoryId]]]), display: false }); expect(viewport.changeCategoryDisplay).toHaveBeenCalledWith({ categoryIds: [categoryId], display: false, enableAllSubCategories: false }); + expect(viewport.changeSubCategoryDisplay).not.toHaveBeenCalled(); }); - it("disables category and subcategories", async () => { - await enableCategoryDisplay(viewport, categoryId, false, true); - expect(viewport.changeCategoryDisplay).toHaveBeenCalledWith({ categoryIds: [categoryId], display: false, enableAllSubCategories: true }); - expect(viewport.changeSubCategoryDisplay).toHaveBeenCalledWith({ subCategoryId, display: false }); - }); - - it("removes overrides per model when enabling category", async () => { + it("removes overrides per model when enabling category", () => { const overrides = [{ modelId: "ModelId", categoryId, visible: false }]; viewport.perModelCategoryOverrides = overrides; - await enableCategoryDisplay(viewport, categoryId, true, false); + changeCategoryDisplay({ viewport, categoryInfos: new Map([[categoryId, [subCategoryId]]]), display: true }); expect(viewport.changeCategoryDisplay).toHaveBeenCalledWith({ categoryIds: [categoryId], display: true, enableAllSubCategories: false }); - expect(viewport.setPerModelCategoryOverride).toHaveBeenCalledWith({ - modelIds: new Set(["ModelId"]), - categoryIds: new Set([categoryId]), - override: "none", + expect(viewport.getPerModelCategoryOverride({ modelId: "ModelId", categoryId })).toBe("none"); + }); + }); + + describe("mergeVisibilityStatuses", () => { + it("returns `visible` when all statuses are visible", async () => { + const result = await firstValueFrom(from([createVisibilityStatus("visible"), createVisibilityStatus("visible")]).pipe(mergeVisibilityStatuses())); + expect(result.state).toEqual("visible"); + }); + + it("returns `hidden` when all statuses are hidden", async () => { + const result = await firstValueFrom(from([createVisibilityStatus("hidden"), createVisibilityStatus("hidden")]).pipe(mergeVisibilityStatuses())); + expect(result.state).toEqual("hidden"); + }); + + it("returns `partial` when statuses differ", async () => { + const result = await firstValueFrom(from([createVisibilityStatus("visible"), createVisibilityStatus("hidden")]).pipe(mergeVisibilityStatuses())); + expect(result.state).toEqual("partial"); + }); + + it("returns `partial` when one of the statuses is partial", async () => { + const result = await firstValueFrom(from([createVisibilityStatus("visible"), createVisibilityStatus("partial")]).pipe(mergeVisibilityStatuses())); + expect(result.state).toEqual("partial"); + }); + + it("emits nothing when there are no statuses", async () => { + const result = await firstValueFrom(EMPTY.pipe(mergeVisibilityStatuses(), toArray())); + expect(result).toEqual([]); + }); + }); + + describe("getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl", () => { + it("returns default status when there are no elements", () => { + const numberOfElementsInOppositeSet = 0; + const totalCount = 0; + const resultVisible = getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl({ + numberOfElementsInOppositeSet, + totalCount, + defaultStatus: createVisibilityStatus("visible"), + }); + expect(resultVisible.state).toEqual("visible"); + const resultHidden = getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl({ + numberOfElementsInOppositeSet, + totalCount, + defaultStatus: createVisibilityStatus("hidden"), + }); + expect(resultHidden.state).toEqual("hidden"); + }); + + it("returns default status when opposite set is empty", () => { + const numberOfElementsInOppositeSet = 0; + const totalCount = 5; + const resultVisible = getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl({ + numberOfElementsInOppositeSet, + totalCount, + defaultStatus: createVisibilityStatus("visible"), + }); + expect(resultVisible.state).toEqual("visible"); + const resultHidden = getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl({ + numberOfElementsInOppositeSet, + totalCount, + defaultStatus: createVisibilityStatus("hidden"), }); + expect(resultHidden.state).toEqual("hidden"); + }); + + it("returns inverted status when all elements are in opposite set", () => { + expect( + getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl({ + numberOfElementsInOppositeSet: 5, + totalCount: 5, + defaultStatus: createVisibilityStatus("visible"), + }).state, + ).toEqual("hidden"); + expect( + getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl({ + numberOfElementsInOppositeSet: 5, + totalCount: 5, + defaultStatus: createVisibilityStatus("hidden"), + }).state, + ).toEqual("visible"); + }); + + it("returns `partial` when some elements are in opposite set", () => { + const numberOfElementsInOppositeSet = 2; + const totalCount = 5; + const result1 = getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl({ + numberOfElementsInOppositeSet, + totalCount, + defaultStatus: createVisibilityStatus("visible"), + }); + expect(result1.state).toEqual("partial"); + const result2 = getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl({ + numberOfElementsInOppositeSet, + totalCount, + defaultStatus: createVisibilityStatus("hidden"), + }); + expect(result2.state).toEqual("partial"); + }); + }); + + describe("changeElementStateNoChildrenOperator", () => { + it("removes element from never drawn list when turning it on", async () => { + using testViewport = createFakeViewport({ neverDrawn: new Set(["0x1"]) }); + await firstValueFrom( + from([{ elementId: "0x1", matchesDesiredState: true }]).pipe(changeElementStateNoChildrenOperator({ on: true, viewport: testViewport })), + ); + expect(testViewport.setNeverDrawn).toHaveBeenCalledWith({ elementIds: new Set() }); + expect(testViewport.setAlwaysDrawn).not.toHaveBeenCalled(); + }); + + it("adds element to always drawn list when turning it on and it does not match desired state", async () => { + using testViewport = createFakeViewport(); + await firstValueFrom( + from([{ elementId: "0x1", matchesDesiredState: false }]).pipe(changeElementStateNoChildrenOperator({ on: true, viewport: testViewport })), + ); + expect(testViewport.setAlwaysDrawn).toHaveBeenCalledWith({ elementIds: new Set(["0x1"]), exclusive: false }); + }); + + it("adds element to always drawn list when turning it on and exclusive mode is enabled", async () => { + using testViewport = createFakeViewport({ isAlwaysDrawnExclusive: true }); + await firstValueFrom( + from([{ elementId: "0x1", matchesDesiredState: true }]).pipe(changeElementStateNoChildrenOperator({ on: true, viewport: testViewport })), + ); + expect(testViewport.setAlwaysDrawn).toHaveBeenCalledWith({ elementIds: new Set(["0x1"]), exclusive: true }); + }); + + it("removes element from always drawn list when turning it off", async () => { + using testViewport = createFakeViewport({ alwaysDrawn: new Set(["0x1"]) }); + await firstValueFrom( + from([{ elementId: "0x1", matchesDesiredState: true }]).pipe(changeElementStateNoChildrenOperator({ on: false, viewport: testViewport })), + ); + expect(testViewport.setAlwaysDrawn).toHaveBeenCalledWith({ elementIds: new Set(), exclusive: false }); + expect(testViewport.setNeverDrawn).not.toHaveBeenCalled(); + }); + + it("adds element to never drawn list when turning it off and it does not match desired state", async () => { + using testViewport = createFakeViewport(); + await firstValueFrom( + from([{ elementId: "0x1", matchesDesiredState: false }]).pipe(changeElementStateNoChildrenOperator({ on: false, viewport: testViewport })), + ); + expect(testViewport.setNeverDrawn).toHaveBeenCalledWith({ elementIds: new Set(["0x1"]) }); + }); + + it("does not add element to never drawn list when turning it off and exclusive mode is enabled", async () => { + using testViewport = createFakeViewport({ isAlwaysDrawnExclusive: true }); + await firstValueFrom( + from([{ elementId: "0x1", matchesDesiredState: false }]).pipe(changeElementStateNoChildrenOperator({ on: false, viewport: testViewport })), + ); + expect(testViewport.setNeverDrawn).not.toHaveBeenCalled(); + }); + }); + + describe("showAll", () => { + it("turns on models, categories and sub-categories and clears always and never drawn lists", () => { + showAll({ + viewport, + modelIds: ["0x10"], + categoryInfos: new Map([[categoryId, [subCategoryId]]]), + }); + + expect(viewport.clearAlwaysDrawn).toHaveBeenCalled(); + expect(viewport.clearNeverDrawn).toHaveBeenCalled(); + expect(viewport.changeModelDisplay).toHaveBeenCalledWith({ modelIds: ["0x10"], display: true }); + expect(viewport.changeCategoryDisplay).toHaveBeenCalledWith({ categoryIds: [categoryId], display: true, enableAllSubCategories: false }); + expect(viewport.changeSubCategoryDisplay).toHaveBeenCalledWith({ subCategoryId, display: true }); + }); + + it("applies non-category changes and an empty category update when there are no categories", () => { + showAll({ + viewport, + modelIds: ["0x10"], + categoryInfos: new Map(), + }); + + expect(viewport.clearAlwaysDrawn).toHaveBeenCalled(); + expect(viewport.clearNeverDrawn).toHaveBeenCalled(); + expect(viewport.changeModelDisplay).toHaveBeenCalledWith({ modelIds: ["0x10"], display: true }); + expect(viewport.changeCategoryDisplay).toHaveBeenCalledWith({ categoryIds: [], display: true, enableAllSubCategories: false }); + }); + }); + + describe("hideAllCategories", () => { + it("turns off categories and clears always drawn list", () => { + hideAllCategories({ + viewport, + categoryInfos: new Map([[categoryId, [subCategoryId]]]), + }); + + expect(viewport.clearAlwaysDrawn).toHaveBeenCalled(); + expect(viewport.changeCategoryDisplay).toHaveBeenCalledWith({ categoryIds: [categoryId], display: false, enableAllSubCategories: false }); + expect(viewport.changeSubCategoryDisplay).not.toHaveBeenCalled(); + expect(viewport.changeModelDisplay).not.toHaveBeenCalled(); + expect(viewport.clearNeverDrawn).not.toHaveBeenCalled(); + }); + + it("applies non-category changes and an empty category update when there are no categories", () => { + hideAllCategories({ + viewport, + categoryInfos: new Map(), + }); + + expect(viewport.clearAlwaysDrawn).toHaveBeenCalled(); + expect(viewport.changeCategoryDisplay).toHaveBeenCalledWith({ categoryIds: [], display: false, enableAllSubCategories: false }); + }); + }); + + describe("invertAllModels", () => { + it("turns on hidden models and turns off visible ones", () => { + using testViewport = createFakeViewport({ viewsModel: (modelId) => modelId === "0x10", viewsSubCategory: () => false }); + + invertAllModels({ + viewport: testViewport, + modelIds: ["0x10", "0x20"], + categoryInfos: new Map([[categoryId, [subCategoryId]]]), + }); + + expect(testViewport.changeModelDisplay).toHaveBeenCalledWith({ modelIds: ["0x20"], display: true }); + expect(testViewport.changeModelDisplay).toHaveBeenCalledWith({ modelIds: ["0x10"], display: false }); + }); + + it("turns on all categories and sub-categories and clears always drawn, never drawn lists and per model overrides", () => { + using testViewport = createFakeViewport({ viewsModel: () => false, viewsSubCategory: () => false }); + + invertAllModels({ + viewport: testViewport, + modelIds: ["0x10"], + categoryInfos: new Map([[categoryId, [subCategoryId]]]), + }); + + expect(testViewport.clearAlwaysDrawn).toHaveBeenCalled(); + expect(testViewport.clearNeverDrawn).toHaveBeenCalled(); + expect(testViewport.changeCategoryDisplay).toHaveBeenCalledWith({ categoryIds: [categoryId], display: true }); + expect(testViewport.changeSubCategoryDisplay).toHaveBeenCalledWith({ subCategoryId, display: true }); + }); + + it("does not change sub-category display when it is already visible", () => { + using testViewport = createFakeViewport({ viewsModel: () => false, viewsSubCategory: () => true }); + + invertAllModels({ + viewport: testViewport, + modelIds: ["0x10"], + categoryInfos: new Map([[categoryId, [subCategoryId]]]), + }); + + expect(testViewport.changeSubCategoryDisplay).not.toHaveBeenCalled(); }); }); @@ -163,81 +411,43 @@ describe("VisibilityUtils", () => { await terminateCore(); }); - it("inverts visible and hidden categories", async () => { + it("inverts visible and hidden categories", () => { nonMockedViewport.changeCategoryDisplay({ categoryIds: [categoryIds[0]], display: false, enableAllSubCategories: true }); nonMockedViewport.changeCategoryDisplay({ categoryIds: [categoryIds[1], categoryIds[2]], display: true, enableAllSubCategories: true }); for (let i = 0; i < categoryIds.length; ++i) { expect(nonMockedViewport.viewsCategory(categoryIds[i])).toBe(i > 0); } - await invertAllCategories( - categoryIds.map((id) => ({ categoryId: id })), - nonMockedViewport, - ); - for (let i = 0; i < categoryIds.length; ++i) { - expect(nonMockedViewport.viewsCategory(categoryIds[i])).toBe(i === 0); - } - }); - - it("enables categories when they are in partial state due to subcategories", async () => { - nonMockedViewport.changeCategoryDisplay({ categoryIds: categoryIds[0], display: true, enableAllSubCategories: true }); - nonMockedViewport.changeCategoryDisplay({ categoryIds: categoryIds[1], display: false, enableAllSubCategories: true }); - nonMockedViewport.changeSubCategoryDisplay({ subCategoryId: subCategoryIds[0], display: false }); - nonMockedViewport.changeSubCategoryDisplay({ subCategoryId: subCategoryIds[1], display: true }); - for (let i = 0; i < categoryIds.length; ++i) { - expect(nonMockedViewport.viewsCategory(categoryIds[i])).toBe(i === 0); - } - for (let i = 0; i < subCategoryIds.length; ++i) { - expect(nonMockedViewport.viewsSubCategory(subCategoryIds[i])).toBe(i !== 0); - } - await invertAllCategories( - categoryIds.map((id, index) => ({ categoryId: id, subCategoryIds: [subCategoryIds[index]] })), - nonMockedViewport, - ); - for (const id of categoryIds) { - expect(nonMockedViewport.viewsCategory(id)).toBe(true); - } - for (const id of subCategoryIds) { - expect(nonMockedViewport.viewsSubCategory(id)).toBe(true); - } - }); - - it("enables categories when they are in partial state due to per model overrides", async () => { - nonMockedViewport.changeCategoryDisplay({ categoryIds: categoryIds[0], display: true, enableAllSubCategories: true }); - nonMockedViewport.changeCategoryDisplay({ categoryIds: categoryIds[1], display: false, enableAllSubCategories: true }); + invertAllCategories({ + categoryInfos: new Map(categoryIds.map((id, index) => [id, [subCategoryIds[index]]])), + modelIds, + viewport: nonMockedViewport, + }); for (let i = 0; i < categoryIds.length; ++i) { expect(nonMockedViewport.viewsCategory(categoryIds[i])).toBe(i === 0); } - nonMockedViewport.setPerModelCategoryOverride({ modelIds: modelIds[0], categoryIds: categoryIds[0], override: "hide" }); - nonMockedViewport.setPerModelCategoryOverride({ modelIds: modelIds[1], categoryIds: categoryIds[1], override: "show" }); - await invertAllCategories( - categoryIds.map((id) => ({ categoryId: id })), - nonMockedViewport, - ); - for (const id of categoryIds) { - expect(nonMockedViewport.viewsCategory(id)).toBe(true); - expect(nonMockedViewport.getPerModelCategoryOverride({ modelId: modelIds[0], categoryId: id })).toBe("none"); - expect(nonMockedViewport.getPerModelCategoryOverride({ modelId: modelIds[1], categoryId: id })).toBe("none"); - } }); - it("inverts visible and hidden categories when they have overrides", async () => { + it("clears always/never drawn sets and per model category overrides ", () => { nonMockedViewport.changeCategoryDisplay({ categoryIds: categoryIds[0], display: true, enableAllSubCategories: true }); nonMockedViewport.changeCategoryDisplay({ categoryIds: categoryIds[1], display: false, enableAllSubCategories: true }); - - for (let i = 0; i < categoryIds.length; ++i) { - expect(nonMockedViewport.viewsCategory(categoryIds[i])).toBe(i === 0); - } + nonMockedViewport.setAlwaysDrawn({ elementIds: new Set(["element1"]) }); + nonMockedViewport.setNeverDrawn({ elementIds: new Set(["element2"]) }); nonMockedViewport.setPerModelCategoryOverride({ modelIds: modelIds[0], categoryIds: categoryIds[0], override: "show" }); nonMockedViewport.setPerModelCategoryOverride({ modelIds: modelIds[1], categoryIds: categoryIds[1], override: "hide" }); - await invertAllCategories( - categoryIds.map((id) => ({ categoryId: id })), - nonMockedViewport, - ); - for (let i = 0; i < categoryIds.length; ++i) { - expect(nonMockedViewport.viewsCategory(categoryIds[i])).toBe(i !== 0); - expect(nonMockedViewport.getPerModelCategoryOverride({ modelId: modelIds[0], categoryId: categoryIds[i] })).toBe("none"); - expect(nonMockedViewport.getPerModelCategoryOverride({ modelId: modelIds[1], categoryId: categoryIds[i] })).toBe("none"); - } + expect(nonMockedViewport.alwaysDrawn?.size).toBe(1); + invertAllCategories({ + categoryInfos: new Map(categoryIds.map((id, index) => [id, [subCategoryIds[index]]])), + modelIds, + viewport: nonMockedViewport, + }); + expect(nonMockedViewport.viewsCategory(categoryIds[0])).toBe(false); + expect(nonMockedViewport.viewsCategory(categoryIds[1])).toBe(true); + expect(nonMockedViewport.viewsSubCategory(subCategoryIds[0])).toBe(true); + expect(nonMockedViewport.viewsSubCategory(subCategoryIds[1])).toBe(true); + expect(nonMockedViewport.alwaysDrawn?.size).toBe(0); + expect(nonMockedViewport.neverDrawn?.size).toBe(0); + expect(nonMockedViewport.getPerModelCategoryOverride({ modelId: modelIds[0], categoryId: categoryIds[0] })).toBe("none"); + expect(nonMockedViewport.getPerModelCategoryOverride({ modelId: modelIds[1], categoryId: categoryIds[1] })).toBe("none"); }); }); }); diff --git a/packages/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeButtons.tsx b/packages/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeButtons.tsx index dbf0ccb90..743a8f379 100644 --- a/packages/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeButtons.tsx +++ b/packages/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeButtons.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { useCallback, useEffect, useMemo, useState } from "react"; -import { firstValueFrom, forkJoin, mergeAll, mergeMap, of, toArray } from "rxjs"; +import { defaultIfEmpty, firstValueFrom, forkJoin, from, map, mergeAll, mergeMap, of, reduce, takeUntil, toArray } from "rxjs"; import { useAsyncValue } from "@itwin/components-react"; import { IconButton } from "@stratakit/bricks"; import visibilityHideSvg from "@stratakit/icons/visibility-hide.svg"; @@ -14,12 +14,14 @@ import { useTranslation } from "../common/components/LocalizationContext.js"; import { useErrorState } from "../common/internal/hooks/UseErrorState.js"; import { useSharedTreeContextInternal } from "../common/internal/SharedTreeContextProviderInternal.js"; import { getClassesByView } from "../common/internal/Utils.js"; -import { enableCategoryDisplay, invertAllCategories } from "../common/internal/VisibilityUtils.js"; -import { showAll } from "../common/Utils.js"; +import { hideAllCategories, invertAllCategories, showAll } from "../common/internal/VisibilityUtils.js"; +import type { Observable } from "rxjs"; import type { Id64Array, Id64String } from "@itwin/core-bentley"; import type { TreeToolbarButtonProps } from "../../tree-header/SelectableTree.js"; +import type { BaseIdsCache } from "../common/internal/caches/BaseIdsCache.js"; import type { ModelId } from "../common/internal/Types.js"; +import type { CategoryInfosMap } from "../common/internal/VisibilityUtils.js"; import type { TreeWidgetViewport } from "../common/TreeWidgetViewport.js"; /** @@ -90,67 +92,109 @@ export type CategoriesTreeHeaderButtonType = (props: CategoriesTreeHeaderButtonP /** @public */ export function ShowAllButton(props: CategoriesTreeHeaderButtonProps) { - const { cancelChangesInProgress } = useSharedTreeContextInternal(); + const { categories, viewport, onFeatureUsed, models } = props; + const { cancelChangesInProgress, getBaseIdsCache } = useSharedTreeContextInternal(); + const viewType = viewport.viewType === "2d" ? "2d" : "3d"; + const baseIdsCache = getBaseIdsCache({ imodel: viewport.iModel, elementClassName: getClassesByView(viewType).elementClass, type: viewType }); const translate = useTranslation(); + + const onClick = async () => { + // cspell:disable-next-line + onFeatureUsed?.(`categories-tree-showall`); + cancelChangesInProgress.next(); + // wrap in try catch for getCategoryInfos call + try { + const categoryInfos = await getCategoryInfos({ categoriesInfo: categories, baseIdsCache, cancel: cancelChangesInProgress }); + if (!categoryInfos) { + return; + } + showAll({ + viewport, + modelIds: models, + categoryInfos, + }); + } catch {} + }; + return ( { - // cspell:disable-next-line - props.onFeatureUsed?.(`categories-tree-showall`); - cancelChangesInProgress.next(); - void showAll({ - models: props.models, - viewport: props.viewport, - categories: props.categories.map((category) => category.categoryId), - }).catch(() => {}); - }} + onClick={onClick} icon={visibilityShowSvg} + aria-disabled={categories.length === 0} /> ); } /** @public */ export function HideAllButton(props: CategoriesTreeHeaderButtonProps) { - const { cancelChangesInProgress } = useSharedTreeContextInternal(); + const { categories, viewport, onFeatureUsed } = props; + const { cancelChangesInProgress, getBaseIdsCache } = useSharedTreeContextInternal(); + const viewType = viewport.viewType === "2d" ? "2d" : "3d"; + const baseIdsCache = getBaseIdsCache({ imodel: viewport.iModel, elementClassName: getClassesByView(viewType).elementClass, type: viewType }); const translate = useTranslation(); + + const onClick = async () => { + // cspell:disable-next-line + onFeatureUsed?.(`categories-tree-hideall`); + cancelChangesInProgress.next(); + // wrap in try catch for getCategoryInfos call + try { + const categoryInfos = await getCategoryInfos({ categoriesInfo: categories, baseIdsCache, cancel: cancelChangesInProgress }); + if (!categoryInfos) { + return; + } + hideAllCategories({ + viewport, + categoryInfos, + }); + } catch {} + }; return ( { - // cspell:disable-next-line - props.onFeatureUsed?.(`categories-tree-hideall`); - cancelChangesInProgress.next(); - void enableCategoryDisplay( - props.viewport, - props.categories.map((category) => category.categoryId), - false, - false, - ); - - props.viewport.changeModelDisplay({ modelIds: props.models, display: false }); - }} + onClick={onClick} icon={visibilityHideSvg} + aria-disabled={categories.length === 0} /> ); } /** @public */ export function InvertAllButton(props: CategoriesTreeHeaderButtonProps) { - const { cancelChangesInProgress } = useSharedTreeContextInternal(); + const { categories, viewport, onFeatureUsed, models } = props; + const { cancelChangesInProgress, getBaseIdsCache } = useSharedTreeContextInternal(); + const viewType = viewport.viewType === "2d" ? "2d" : "3d"; + const baseIdsCache = getBaseIdsCache({ imodel: viewport.iModel, elementClassName: getClassesByView(viewType).elementClass, type: viewType }); const translate = useTranslation(); + + const onClick = async () => { + // cspell:disable-next-line + onFeatureUsed?.(`categories-tree-invert`); + cancelChangesInProgress.next(); + // wrap in try catch for getCategoryInfos call + try { + const categoryInfos = await getCategoryInfos({ categoriesInfo: categories, baseIdsCache, cancel: cancelChangesInProgress }); + if (!categoryInfos) { + return; + } + invertAllCategories({ + viewport, + modelIds: models, + categoryInfos, + }); + } catch {} + }; + return ( { - props.onFeatureUsed?.(`categories-tree-invert`); - cancelChangesInProgress.next(); - void invertAllCategories(props.categories, props.viewport); - }} + onClick={onClick} icon={visibilityInvertSvg} + aria-disabled={categories.length === 0} /> ); } @@ -212,3 +256,32 @@ function useAvailableModels(viewport: TreeWidgetViewport): Array { return availableModels; } + +async function getCategoryInfos({ + categoriesInfo, + baseIdsCache, + cancel, +}: { + categoriesInfo: CategoryInfo[]; + baseIdsCache: BaseIdsCache; + cancel: Observable; +}): Promise { + return firstValueFrom( + from(categoriesInfo).pipe( + mergeMap((categoryInfo) => { + if (categoryInfo.subCategoryIds && categoryInfo.subCategoryIds.length > 0) { + return of({ categoryId: categoryInfo.categoryId, subCategoryIds: categoryInfo.subCategoryIds }); + } + return baseIdsCache + .getSubCategories({ categoryId: categoryInfo.categoryId }) + .pipe(map((subCategoryIds) => ({ categoryId: categoryInfo.categoryId, subCategoryIds }))); + }), + reduce((acc: CategoryInfosMap, category) => { + acc.set(category.categoryId, category.subCategoryIds); + return acc; + }, new Map()), + takeUntil(cancel), + defaultIfEmpty(undefined), + ), + ); +} diff --git a/packages/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeComponent.tsx b/packages/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeComponent.tsx index 4e4139f60..6bca80fc6 100644 --- a/packages/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeComponent.tsx +++ b/packages/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeComponent.tsx @@ -5,9 +5,11 @@ import { Fragment } from "react"; import { useActiveIModelConnection } from "@itwin/appui-react"; +import { Skeleton } from "@stratakit/bricks"; import { SelectableTree } from "../../tree-header/SelectableTree.js"; import { useActiveTreeWidgetViewport } from "../common/internal/hooks/UseActiveTreeWidgetViewport.js"; -import { SharedTreeContextProviderInternal } from "../common/internal/SharedTreeContextProviderInternal.js"; +import { SharedTreeContextProviderInternal, useSharedTreeContextInternal } from "../common/internal/SharedTreeContextProviderInternal.js"; +import { getClassesByView } from "../common/internal/Utils.js"; import { TelemetryContextProvider } from "../common/UseTelemetryContext.js"; import { CategoriesTree } from "./CategoriesTree.js"; import { HideAllButton, InvertAllButton, ShowAllButton, useCategoriesTreeButtonProps } from "./CategoriesTreeButtons.js"; @@ -117,14 +119,21 @@ function CategoriesTreeComponentImpl({ ...treeProps }: CategoriesTreeComponentProps & { iModel: IModelConnection; viewport: TreeWidgetViewport }) { const { buttonProps, onCategoriesFiltered } = useCategoriesTreeButtonProps({ viewport }); + const { getBaseIdsCache } = useSharedTreeContextInternal(); + const viewType = viewport.viewType === "2d" ? "2d" : "3d"; + const isLoaded = + buttonProps.categories.length > 0 || + getBaseIdsCache({ imodel: viewport.iModel, elementClassName: getClassesByView(viewType).elementClass, type: viewType }).elementModelCategoriesLoaded(); - const buttons: ReactNode = headerButtons - ? headerButtons.map((btn, index) => {btn({ ...buttonProps, onFeatureUsed })}) - : [ - , - , - , - ]; + const buttons: ReactNode = isLoaded + ? headerButtons + ? headerButtons.map((btn, index) => {btn({ ...buttonProps, onFeatureUsed })}) + : [ + , + , + , + ] + : Array.from({ length: headerButtons?.length ?? 3 }, (_, index) => ); return ( diff --git a/packages/tree-widget/src/tree-widget-react/components/trees/common/Utils.ts b/packages/tree-widget/src/tree-widget-react/components/trees/common/Utils.ts index 5e10fe03d..152495cdd 100644 --- a/packages/tree-widget/src/tree-widget-react/components/trees/common/Utils.ts +++ b/packages/tree-widget/src/tree-widget-react/components/trees/common/Utils.ts @@ -4,10 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { HierarchySearchTree } from "@itwin/presentation-hierarchies"; -import { enableCategoryDisplay } from "./internal/VisibilityUtils.js"; - -import type { Id64Array, Id64String } from "@itwin/core-bentley"; -import type { TreeWidgetViewport } from "./TreeWidgetViewport.js"; /** * This is a logging namespace for public log messages that may be interesting to consumers. @@ -18,62 +14,6 @@ export const LOGGING_NAMESPACE = "TreeWidget"; /** @beta */ export type FunctionProps any> = Parameters[0]; -/** - * Enables display of all given models. Also enables display of all categories and clears always and - * never drawn lists in the viewport. - * @internal - */ -export async function showAll(props: { - /** ID's of models to enable */ - models: Id64Array; - /** ID's of categories to enable */ - categories: Id64Array; - viewport: TreeWidgetViewport; -}) { - const { models, categories, viewport } = props; - await enableCategoryDisplay(viewport, categories, true, true); - viewport.changeModelDisplay({ modelIds: models, display: true }); - viewport.clearNeverDrawn(); - viewport.clearAlwaysDrawn(); -} - -/** - * Inverts display of all given models. - * @internal - */ -export function invertAllModels(models: Id64Array, viewport: TreeWidgetViewport) { - const notViewedModels = new Array(); - const viewedModels = new Array(); - models.forEach((modelId) => { - if (viewport.viewsModel(modelId)) { - viewedModels.push(modelId); - } else { - notViewedModels.push(modelId); - } - }); - viewport.changeModelDisplay({ modelIds: notViewedModels, display: true }); - viewport.changeModelDisplay({ modelIds: viewedModels, display: false }); -} - -/** - * Based on the value of `enable` argument, either enables or disables display of given models. - * @internal - */ -export function toggleModels(models: string[], enable: boolean, viewport: TreeWidgetViewport) { - if (!models) { - return; - } - viewport.changeModelDisplay({ modelIds: models, display: enable }); -} - -/** - * Checks if all given models are displayed in given viewport. - * @internal - */ -export function areAllModelsVisible(models: string[], viewport: TreeWidgetViewport) { - return models.length !== 0 ? models.every((id) => viewport.viewsModel(id)) : false; -} - /** @internal */ export function joinHierarchySearchTrees(subTrees: HierarchySearchTree[], searchTrees: HierarchySearchTree[]): HierarchySearchTree[] { const builder = HierarchySearchTree.createBuilder<{ diff --git a/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/Utils.ts b/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/Utils.ts index 835e42758..770e565f1 100644 --- a/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/Utils.ts +++ b/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/Utils.ts @@ -36,6 +36,18 @@ export function setDifference(lhs: ReadonlySet, rhs: ReadonlySet): Set< return result; } +/** @internal */ +export function setIntersection(lhs: ReadonlySet, rhs: ReadonlySet): Set { + const result = new Set(); + const { smallerSet, largerSet } = lhs.size < rhs.size ? { smallerSet: lhs, largerSet: rhs } : { smallerSet: rhs, largerSet: lhs }; + for (const x of smallerSet) { + if (largerSet.has(x)) { + result.add(x); + } + } + return result; +} + /** @internal */ export function countInSet(ids: Id64Arg, set: ReadonlySet | undefined): number { if (!set?.size) { diff --git a/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/VisibilityUtils.ts b/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/VisibilityUtils.ts index 7526361a9..c175f819d 100644 --- a/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/VisibilityUtils.ts +++ b/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/VisibilityUtils.ts @@ -3,19 +3,16 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { bufferCount, EMPTY, map, mergeMap, of, reduce } from "rxjs"; -import { Id64 } from "@itwin/core-bentley"; -import { fromWithRelease, reduceWhile, releaseMainThreadOnItemsCount, toVoidPromise } from "./Rxjs.js"; +import { EMPTY, map, mergeMap, of, reduce } from "rxjs"; +import { reduceWhile, releaseMainThreadOnItemsCount } from "./Rxjs.js"; import { createVisibilityStatus } from "./Tooltip.js"; -import { getOptimalBatchSize } from "./Utils.js"; import type { Observable, OperatorFunction } from "rxjs"; -import type { Id64Arg, Id64Array, Id64Set, Id64String } from "@itwin/core-bentley"; -import type { CategoryInfo } from "../../categories-tree/CategoriesTreeButtons.js"; +import type { Id64Array, Id64String } from "@itwin/core-bentley"; import type { TreeWidgetViewport } from "../TreeWidgetViewport.js"; import type { VisibilityStatus } from "../UseHierarchyVisibility.js"; import type { NonPartialVisibilityStatus, Visibility } from "./Tooltip.js"; -import type { ElementId } from "./Types.js"; +import type { CategoryId, ElementId, SubCategoryId } from "./Types.js"; function mergeVisibilities(obs: Observable): Observable { return obs.pipe( @@ -120,77 +117,140 @@ export function getCategoryVisibilityFromAlwaysAndNeverDrawnElementsImpl(props: return createVisibilityStatus("partial"); } +/** @internal */ +export type CategoryInfosMap = Map | undefined>; + /** - * Changes category display in the viewport. + * Turns display of given categories and their sub-categories on or off, clearing any per-model category overrides. + * + * Categories are processed in batches, releasing the main thread in between, so the returned observable may emit over multiple frames. * @internal */ -export async function enableCategoryDisplay(viewport: TreeWidgetViewport, categoryIds: Id64Arg, enabled: boolean, enableAllSubCategories = true) { - const removeOverrides = (bufferedCategories: Id64Set) => { - const modelsContainingOverrides = new Set(); - for (const ovr of viewport.perModelCategoryOverrides) { - if (bufferedCategories.has(ovr.categoryId)) { - modelsContainingOverrides.add(ovr.modelId); - } +export function changeCategoryDisplay({ + categoryInfos, + viewport, + display, +}: { + viewport: TreeWidgetViewport; + categoryInfos: CategoryInfosMap; + display: boolean; +}): void { + const modelsContainingOverrides = new Set(); + const categoriesArray = [...categoryInfos.keys()]; + for (const ovr of viewport.perModelCategoryOverrides) { + if (categoryInfos.has(ovr.categoryId)) { + modelsContainingOverrides.add(ovr.modelId); } - viewport.setPerModelCategoryOverride({ modelIds: modelsContainingOverrides, categoryIds: bufferedCategories, override: "none" }); - }; - const disableSubCategories = async (bufferedCategories: Id64Array) => { - // changeCategoryDisplay only enables subcategories, it does not disabled them. So we must do that ourselves. - const categoryInfo = await viewport.iModel.categories.getCategoryInfo(bufferedCategories); - for (const info of categoryInfo.values()) { - for (const value of info.subCategories.values()) { - viewport.changeSubCategoryDisplay({ subCategoryId: value.id, display: false }); + } + viewport.setPerModelCategoryOverride({ modelIds: modelsContainingOverrides, categoryIds: categoriesArray, override: "none" }); + viewport.changeCategoryDisplay({ categoryIds: categoriesArray, display, enableAllSubCategories: false }); + if (!display) { + return; + } + for (const categoryId of categoriesArray) { + const subCategoryIds = categoryInfos.get(categoryId); + for (const subCategoryId of subCategoryIds ?? []) { + viewport.changeSubCategoryDisplay({ subCategoryId, display }); + } + } +} + +/** + * Makes everything visible: displays all given models, categories and sub-categories, and clears the always/never drawn element lists. + * @internal + */ +export function showAll({ viewport, modelIds, categoryInfos }: { viewport: TreeWidgetViewport; modelIds: Id64Array; categoryInfos: CategoryInfosMap }) { + viewport.clearAlwaysDrawn(); + viewport.clearNeverDrawn(); + viewport.changeModelDisplay({ modelIds, display: true }); + changeCategoryDisplay({ + viewport, + categoryInfos, + display: true, + }); +} + +/** + * Hides all given categories and their sub-categories, and clears the always drawn element list. + * @internal + */ +export function hideAllCategories({ viewport, categoryInfos }: { viewport: TreeWidgetViewport; categoryInfos: CategoryInfosMap }) { + viewport.clearAlwaysDrawn(); + changeCategoryDisplay({ + viewport, + categoryInfos, + display: false, + }); +} + +/** + * Invert display of all given models: visible models are hidden and hidden models are made visible. + * + * All given categories and sub-categories are turned on, and always/never drawn element lists and per-model category overrides are cleared, + * so that model display alone determines what is visible. + * @internal + */ +export function invertAllModels({ modelIds, viewport, categoryInfos }: { modelIds: Id64Array; viewport: TreeWidgetViewport; categoryInfos: CategoryInfosMap }) { + viewport.clearNeverDrawn(); + viewport.clearAlwaysDrawn(); + viewport.clearPerModelCategoryOverrides(); + viewport.changeCategoryDisplay({ categoryIds: [...categoryInfos.keys()], display: true }); + for (const subCategoryIds of categoryInfos.values()) { + for (const subCategoryId of subCategoryIds ?? []) { + if (!viewport.viewsSubCategory(subCategoryId)) { + viewport.changeSubCategoryDisplay({ subCategoryId, display: true }); } } - }; - return toVoidPromise( - fromWithRelease({ source: categoryIds, releaseOnCount: 500 }).pipe( - bufferCount(getOptimalBatchSize({ totalSize: Id64.sizeOf(categoryIds), maximumBatchSize: 500 })), - mergeMap(async (bufferedCategories) => { - viewport.changeCategoryDisplay({ categoryIds: bufferedCategories, display: enabled, enableAllSubCategories }); - removeOverrides(new Set(bufferedCategories)); - if (!enabled) { - await disableSubCategories(bufferedCategories); - } - }), - ), - ); + } + const notViewedModels = new Array(); + const viewedModels = new Array(); + for (const modelId of modelIds) { + if (viewport.viewsModel(modelId)) { + viewedModels.push(modelId); + continue; + } + notViewedModels.push(modelId); + } + viewport.changeModelDisplay({ modelIds: notViewedModels, display: true }); + viewport.changeModelDisplay({ modelIds: viewedModels, display: false }); } /** - * Invert display of all given categories. - * Categories are inverted like this: - * - If category is visible, it will be hidden. - * - If category is hidden, it will be visible. - * - If category is partially visible, it will be fully visible. + * Invert display of all given categories: visible categories are hidden and hidden categories are made visible. + * + * All given models are made visible, and always/never drawn element lists and per-model category overrides are cleared, + * so that category display alone determines what is visible. * @internal */ -export async function invertAllCategories(categories: CategoryInfo[], viewport: TreeWidgetViewport) { - const categoriesToEnable = new Set(); - const categoriesToDisable = new Set(); +export function invertAllCategories({ + categoryInfos, + modelIds, + viewport, +}: { + categoryInfos: Map | undefined>; + modelIds: Id64Array; + viewport: TreeWidgetViewport; +}) { + viewport.clearNeverDrawn(); + viewport.clearAlwaysDrawn(); + viewport.changeModelDisplay({ modelIds, display: true }); + + const categoriesToEnable: CategoryInfosMap = new Map(); + const categoriesToDisable: CategoryInfosMap = new Map(); - for (const category of categories) { - if (!viewport.viewsCategory(category.categoryId)) { - categoriesToEnable.add(category.categoryId); + for (const [categoryId, subCategoryIds] of categoryInfos) { + if (!viewport.viewsCategory(categoryId)) { + categoriesToEnable.set(categoryId, subCategoryIds); continue; } // Check if category is in partial state - if (category.subCategoryIds?.some((subCategory) => !viewport.viewsSubCategory(subCategory))) { - categoriesToEnable.add(category.categoryId); + if (subCategoryIds?.some((subCategory) => !viewport.viewsSubCategory(subCategory))) { + categoriesToEnable.set(categoryId, subCategoryIds); } else { - categoriesToDisable.add(category.categoryId); + categoriesToDisable.set(categoryId, subCategoryIds); } } - // collect per model overrides that need to be inverted - for (const { categoryId, visible } of viewport.perModelCategoryOverrides) { - if (!visible && categoriesToDisable.has(categoryId)) { - categoriesToEnable.add(categoryId); - categoriesToDisable.delete(categoryId); - } - } - - await enableCategoryDisplay(viewport, categoriesToDisable, false, true); - - await enableCategoryDisplay(viewport, categoriesToEnable, true, true); + changeCategoryDisplay({ viewport, categoryInfos: categoriesToDisable, display: false }); + changeCategoryDisplay({ viewport, categoryInfos: categoriesToEnable, display: true }); } diff --git a/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/caches/BaseIdsCache.ts b/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/caches/BaseIdsCache.ts index 16e6ae505..5e32c0c45 100644 --- a/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/caches/BaseIdsCache.ts +++ b/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/caches/BaseIdsCache.ts @@ -250,10 +250,28 @@ export class BaseIdsCache { // ElementModelCategoriesCache methods + public elementModelCategoriesLoaded(): boolean { + return this.#elementModelCategoriesCache.cachedDataLoaded(); + } + public getAllModels(): Observable> { return this.#elementModelCategoriesCache.getCachedData().pipe(map(({ modelsCategoriesInfo }) => [...modelsCategoriesInfo.keys()])); } + public getPlanProjectionModels(): Observable { + return this.#elementModelCategoriesCache.getCachedData().pipe( + map(({ modelsCategoriesInfo }) => { + const result = new Set(); + for (const [modelId, modelInfo] of modelsCategoriesInfo) { + if (modelInfo.isPlanProjectionModel) { + result.add(modelId); + } + } + return result; + }), + ); + } + public getCategories({ modelId, includeOnlyIfCategoryOfTopMostElement, diff --git a/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/caches/ElementModelCategoriesCache.ts b/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/caches/ElementModelCategoriesCache.ts index 9d4f6f070..63c38977c 100644 --- a/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/caches/ElementModelCategoriesCache.ts +++ b/packages/tree-widget/src/tree-widget-react/components/trees/common/internal/caches/ElementModelCategoriesCache.ts @@ -20,17 +20,15 @@ interface ElementModelCategoriesCacheProps { elementClassName: string; excludedElementClassNames?: ReadonlyArray; } - +interface ModelsCategoriesInfoEntry { + categoriesOfTopMostElements: Set; + allCategories: Set; + categoriesOfTopMostNonExcludedElements: Set; + nonExcludedCategories: Set; + isPlanProjectionModel: boolean; +} interface CachedData { - modelsCategoriesInfo: Map< - ModelId, - { - categoriesOfTopMostElements: Set; - allCategories: Set; - categoriesOfTopMostNonExcludedElements: Set; - nonExcludedCategories: Set; - } - >; + modelsCategoriesInfo: Map; modelsContainingTopMostNonExcludedElements: Set; categoriesContainingNonExcludedElements: Set; categoryModelsInfo: Map>; @@ -64,6 +62,7 @@ export class ElementModelCategoriesCache { isTopMostElementCategory: boolean; hasParentElements: boolean; hasElementsFromNonExcludedClasses: boolean; + isPlanProjectionModel: boolean; }> { const excludedClause = createExcludedClassesClause({ alias: "this", excludedClassNames: this.#excludedElementClassNames }); return defer(() => { @@ -72,7 +71,8 @@ export class ElementModelCategoriesCache { this.Model.Id modelId, this.Category.Id categoryId, MAX(IIF(this.Parent.Id IS NULL, 1, 0)) isTopMostElementCategory, - MAX(IIF((SELECT 1 FROM ${this.#elementClassName} ce WHERE ce.Parent.Id = this.ECInstanceId LIMIT 1), 1, 0)) hasParentElements + MAX(IIF((SELECT 1 FROM ${this.#elementClassName} ce WHERE ce.Parent.Id = this.ECInstanceId LIMIT 1), 1, 0)) hasParentElements, + IIF(m.$->IsPlanProjection?, 1, 0) isPlanProjectionModel ${excludedClause ? `, MAX(IIF((${excludedClause}), 1, 0)) hasElementsFromNonExcludedClasses` : ""} FROM ${this.#elementClassName} this JOIN ${CLASS_NAME_Model} m ON m.ECInstanceId = this.Model.Id @@ -92,11 +92,16 @@ export class ElementModelCategoriesCache { isTopMostElementCategory: !!row.isTopMostElementCategory, hasParentElements: !!row.hasParentElements, hasElementsFromNonExcludedClasses: excludedClause ? !!row.hasElementsFromNonExcludedClasses : true, + isPlanProjectionModel: !!row.isPlanProjectionModel, }; }), ); } + public cachedDataLoaded() { + return !!this.#dataResolved; + } + public getCachedData() { this.#cachedData ??= this.queryElementModelCategories().pipe( reduce( @@ -115,11 +120,12 @@ export class ElementModelCategoriesCache { const modelEntry = getOrCreate({ map: acc.modelsCategoriesInfo, key: queriedCategory.modelId, - createFunc: () => ({ + createFunc: (): ModelsCategoriesInfoEntry => ({ categoriesOfTopMostElements: new Set(), allCategories: new Set(), categoriesOfTopMostNonExcludedElements: new Set(), nonExcludedCategories: new Set(), + isPlanProjectionModel: queriedCategory.isPlanProjectionModel, }), }); modelEntry.allCategories.add(queriedCategory.categoryId); @@ -141,15 +147,7 @@ export class ElementModelCategoriesCache { return acc; }, { - modelsCategoriesInfo: new Map< - ModelId, - { - categoriesOfTopMostElements: Set; - allCategories: Set; - categoriesOfTopMostNonExcludedElements: Set; - nonExcludedCategories: Set; - } - >(), + modelsCategoriesInfo: new Map(), categoriesWithParentElements: new Set(), allTopMostElementCategories: new Set(), allCategories: new Set(), diff --git a/packages/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeButtons.tsx b/packages/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeButtons.tsx index 45fe78504..40e9d7a2f 100644 --- a/packages/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeButtons.tsx +++ b/packages/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeButtons.tsx @@ -3,8 +3,8 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { firstValueFrom, mergeAll, toArray } from "rxjs"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { defaultIfEmpty, firstValueFrom, forkJoin, mergeAll, mergeMap, of, reduce, takeUntil } from "rxjs"; import { IconButton } from "@stratakit/bricks"; import toggle2DSvg from "@stratakit/icons/2d.svg"; import toggle3DSvg from "@stratakit/icons/3d.svg"; @@ -16,12 +16,15 @@ import { useTranslation } from "../common/components/LocalizationContext.js"; import { useFocusedInstancesContext } from "../common/FocusedInstancesContext.js"; import { useSharedTreeContextInternal } from "../common/internal/SharedTreeContextProviderInternal.js"; import { getClassesByView } from "../common/internal/Utils.js"; -import { areAllModelsVisible, invertAllModels, showAll, toggleModels } from "../common/Utils.js"; +import { invertAllModels, showAll } from "../common/internal/VisibilityUtils.js"; import type { ReactElement } from "react"; +import type { Observable } from "rxjs"; import type { Id64String } from "@itwin/core-bentley"; import type { IModelConnection } from "@itwin/core-frontend"; import type { TreeToolbarButtonProps } from "../../tree-header/SelectableTree.js"; +import type { BaseIdsCache } from "../common/internal/caches/BaseIdsCache.js"; +import type { CategoryInfosMap } from "../common/internal/VisibilityUtils.js"; import type { TreeWidgetViewport } from "../common/TreeWidgetViewport.js"; /** @@ -85,8 +88,11 @@ function useAvailableModels(imodel: IModelConnection): ModelInfo[] { useEffect(() => { const getModels = async () => { try { - const models = await firstValueFrom(baseIdsCache.getAllModels()); - setAvailableModels(models.map((id) => ({ id }))); + const [allModels, planProjectionModels] = await Promise.all([ + firstValueFrom(baseIdsCache.getAllModels()), + firstValueFrom(baseIdsCache.getPlanProjectionModels()), + ]); + setAvailableModels(allModels.map((id) => ({ id, isPlanProjection: planProjectionModels.has(id) }))); } catch { setAvailableModels([]); } @@ -105,97 +111,125 @@ export type ModelsTreeHeaderButtonType = (props: ModelsTreeHeaderButtonProps) => * @public */ export function ShowAllButton(props: ModelsTreeHeaderButtonProps) { + const { models, viewport, onFeatureUsed } = props; const { getBaseIdsCache, cancelChangesInProgress } = useSharedTreeContextInternal(); - const baseIdsCache = getBaseIdsCache({ imodel: props.viewport.iModel, elementClassName: getClassesByView("3d").elementClass, type: "3d" }); + const baseIdsCache = getBaseIdsCache({ imodel: viewport.iModel, elementClassName: getClassesByView("3d").elementClass, type: "3d" }); const translate = useTranslation(); - const onClick = useCallback(async () => { + + const onClick = async () => { + // cspell:disable-next-line + onFeatureUsed?.("models-tree-showall"); + cancelChangesInProgress.next(); + // wrap in try catch for getCategoryInfos call try { - cancelChangesInProgress.next(); - const categories = await firstValueFrom(baseIdsCache.getAllCategoriesOfElements().pipe(mergeAll(), toArray())); - return await showAll({ - models: props.models.map((model) => model.id), - categories, - viewport: props.viewport, + const categoryInfos = await getCategoryInfos({ baseIdsCache, cancel: cancelChangesInProgress }); + if (!categoryInfos) { + return; + } + showAll({ + viewport, + modelIds: models.map((model) => model.id), + categoryInfos, }); } catch {} - }, [baseIdsCache, props.viewport, props.models, cancelChangesInProgress]); + }; + return ( { - // cspell:disable-next-line - props.onFeatureUsed?.("models-tree-showall"); - void onClick(); - }} + onClick={onClick} icon={visibilityShowSvg} + aria-disabled={models.length === 0} /> ); } /** @public */ export function HideAllButton(props: ModelsTreeHeaderButtonProps) { + const { models, viewport, onFeatureUsed } = props; const { cancelChangesInProgress } = useSharedTreeContextInternal(); const translate = useTranslation(); + + const onClick = () => { + // cspell:disable-next-line + onFeatureUsed?.("models-tree-hideall"); + cancelChangesInProgress.next(); + viewport.changeModelDisplay({ modelIds: models.map((model) => model.id), display: false }); + }; + return ( { - // cspell:disable-next-line - props.onFeatureUsed?.("models-tree-hideall"); - cancelChangesInProgress.next(); - props.viewport.changeModelDisplay({ modelIds: props.models.map((model) => model.id), display: false }); - }} + onClick={onClick} icon={visibilityHideSvg} + aria-disabled={models.length === 0} /> ); } /** @public */ export function InvertButton(props: ModelsTreeHeaderButtonProps) { - const { cancelChangesInProgress } = useSharedTreeContextInternal(); + const { models, viewport, onFeatureUsed } = props; + const { cancelChangesInProgress, getBaseIdsCache } = useSharedTreeContextInternal(); + const baseIdsCache = getBaseIdsCache({ imodel: viewport.iModel, elementClassName: getClassesByView("3d").elementClass, type: "3d" }); const translate = useTranslation(); + + const onClick = async () => { + // cspell:disable-next-line + onFeatureUsed?.("models-tree-invert"); + cancelChangesInProgress.next(); + // wrap in try catch for getCategoryInfos call + try { + const categoryInfos = await getCategoryInfos({ baseIdsCache, cancel: cancelChangesInProgress }); + if (!categoryInfos) { + return; + } + invertAllModels({ + viewport, + modelIds: models.map((model) => model.id), + categoryInfos, + }); + } catch {} + }; + return ( { - props.onFeatureUsed?.("models-tree-invert"); - cancelChangesInProgress.next(); - invertAllModels( - props.models.map((model) => model.id), - props.viewport, - ); - }} + onClick={onClick} icon={visibilityInvertSvg} + aria-disabled={models.length === 0} /> ); } +function useAreAllModelsVisible({ modelIds, viewport }: { modelIds: Id64String[]; viewport: TreeWidgetViewport }): boolean { + const subscribe = useCallback((onStoreChange: () => void) => viewport.onDisplayedModelsChanged.addListener(onStoreChange), [viewport]); + const getSnapshot = useCallback(() => (modelIds.length !== 0 ? modelIds.every((id) => viewport.viewsModel(id)) : false), [modelIds, viewport]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + /** @public */ export function View2DButton(props: ModelsTreeHeaderButtonProps) { + const { models, viewport, onFeatureUsed } = props; + const { cancelChangesInProgress } = useSharedTreeContextInternal(); + const models2d = useMemo(() => models.filter((model) => model.isPlanProjection).map((model) => model.id), [models]); + const is2dToggleActive = useAreAllModelsVisible({ modelIds: models2d, viewport }); const translate = useTranslation(); - const models2d = useMemo(() => { - return props.models.filter((model) => model.isPlanProjection).map((model) => model.id); - }, [props.models]); - - const [is2dToggleActive, setIs2dToggleActive] = useState(() => areAllModelsVisible(models2d, props.viewport)); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setIs2dToggleActive(areAllModelsVisible(models2d, props.viewport)); - return props.viewport.onDisplayedModelsChanged.addListener(() => setIs2dToggleActive(areAllModelsVisible(models2d, props.viewport))); - }, [models2d, props.viewport]); + const onClick = () => { + onFeatureUsed?.("models-tree-view2d"); + cancelChangesInProgress.next(); + viewport.changeModelDisplay({ modelIds: models2d, display: is2dToggleActive ? false : true }); + }; return ( { - props.onFeatureUsed?.("models-tree-view2d"); - toggleModels(models2d, is2dToggleActive, props.viewport); - }} + onClick={onClick} aria-disabled={models2d.length === 0} active={is2dToggleActive} icon={toggle2DSvg} @@ -205,27 +239,25 @@ export function View2DButton(props: ModelsTreeHeaderButtonProps) { /** @public */ export function View3DButton(props: ModelsTreeHeaderButtonProps) { - const translate = useTranslation(); + const { models, viewport, onFeatureUsed } = props; + const { cancelChangesInProgress } = useSharedTreeContextInternal(); const models3d = useMemo(() => { - return props.models.filter((model) => !model.isPlanProjection).map((model) => model.id); - }, [props.models]); - - const [is3dToggleActive, setIs3dToggleActive] = useState(() => areAllModelsVisible(models3d, props.viewport)); + return models.filter((model) => !model.isPlanProjection).map((model) => model.id); + }, [models]); + const is3dToggleActive = useAreAllModelsVisible({ modelIds: models3d, viewport }); + const translate = useTranslation(); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setIs3dToggleActive(areAllModelsVisible(models3d, props.viewport)); - return props.viewport.onDisplayedModelsChanged.addListener(() => setIs3dToggleActive(areAllModelsVisible(models3d, props.viewport))); - }, [models3d, props.viewport]); + const onClick = () => { + onFeatureUsed?.("models-tree-view3d"); + cancelChangesInProgress.next(); + viewport.changeModelDisplay({ modelIds: models3d, display: is3dToggleActive ? false : true }); + }; return ( { - props.onFeatureUsed?.("models-tree-view3d"); - toggleModels(models3d, is3dToggleActive, props.viewport); - }} + onClick={onClick} aria-disabled={models3d.length === 0} active={is3dToggleActive} icon={toggle3DSvg} @@ -257,3 +289,18 @@ export function ToggleInstancesFocusButton({ onFeatureUsed, disabled }: { onFeat /> ); } + +async function getCategoryInfos({ baseIdsCache, cancel }: { baseIdsCache: BaseIdsCache; cancel: Observable }): Promise { + return firstValueFrom( + baseIdsCache.getAllCategoriesOfElements().pipe( + mergeAll(), + mergeMap((categoryId) => forkJoin({ categoryId: of(categoryId), subCategories: baseIdsCache.getSubCategories({ categoryId }) })), + reduce((acc: CategoryInfosMap, { categoryId, subCategories }) => { + acc.set(categoryId, subCategories); + return acc; + }, new Map()), + takeUntil(cancel), + defaultIfEmpty(undefined), + ), + ); +} diff --git a/packages/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeComponent.tsx b/packages/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeComponent.tsx index d85e093cf..2104edf94 100644 --- a/packages/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeComponent.tsx +++ b/packages/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeComponent.tsx @@ -5,10 +5,12 @@ import { Fragment, useEffect } from "react"; import { useActiveIModelConnection } from "@itwin/appui-react"; +import { Skeleton } from "@stratakit/bricks"; import { SelectableTree } from "../../tree-header/SelectableTree.js"; import { FocusedInstancesContextProvider, useFocusedInstancesContext } from "../common/FocusedInstancesContext.js"; import { useActiveTreeWidgetViewport } from "../common/internal/hooks/UseActiveTreeWidgetViewport.js"; -import { SharedTreeContextProviderInternal } from "../common/internal/SharedTreeContextProviderInternal.js"; +import { SharedTreeContextProviderInternal, useSharedTreeContextInternal } from "../common/internal/SharedTreeContextProviderInternal.js"; +import { getClassesByView } from "../common/internal/Utils.js"; import { TelemetryContextProvider } from "../common/UseTelemetryContext.js"; import { ModelsTree } from "./ModelsTree.js"; import { @@ -160,17 +162,22 @@ function ModelsTreeComponentImpl({ }: ModelsTreeComponentProps & { iModel: IModelConnection; viewport: TreeWidgetViewport }) { const { buttonProps, onModelsFiltered } = useModelsTreeButtonProps({ imodel: iModel, viewport }); const { enabled: instanceFocusEnabled, toggle: toggleInstanceFocus } = useFocusedInstancesContext(); - - const buttons: ReactNode = headerButtons - ? headerButtons.map((btn, index) => {btn({ ...buttonProps, onFeatureUsed })}) - : [ - , - , - , - , - , - , - ]; + const { getBaseIdsCache } = useSharedTreeContextInternal(); + const isLoaded = + buttonProps.models.length > 0 || + getBaseIdsCache({ imodel: viewport.iModel, elementClassName: getClassesByView("3d").elementClass, type: "3d" }).elementModelCategoriesLoaded(); + const buttons: ReactNode = isLoaded + ? headerButtons + ? headerButtons.map((btn, index) => {btn({ ...buttonProps, onFeatureUsed })}) + : [ + , + , + , + , + , + , + ] + : Array.from({ length: headerButtons?.length ?? 6 }, (_, index) => ); useEffect(() => { if (instanceFocusEnabled && searchText !== undefined) {