diff --git a/.changeset/wise-readers-jog.md b/.changeset/wise-readers-jog.md new file mode 100644 index 00000000..de9cfba6 --- /dev/null +++ b/.changeset/wise-readers-jog.md @@ -0,0 +1,15 @@ +--- +"@itwin/changed-elements-react": minor +--- + +_Frontend Enhancements:_ + +1. Provide consumers a way to inject their own changes and skip using the changed elements service altogether +2. Provide colorization overrides for any special customization logic +3. Provide a callback when changed instances are selected in the UI + +_Backend Enhancements:_ +1. Initial ChangesRpcInterface and ChangesRpcImpl which aim to allow using the Partial EC Change Unifier in a simplified way +2. The Rpc interface allows the app to provide relationships that they care about and marks any related changed ec instance with what relationships were affected that may drive the element for changes + +See VersionCompare initialization options (`changesProvider`, `colorOverrideProvider` and `onInstancesSelected`) for more information. diff --git a/.eslintrc.json b/.eslintrc.json index 0f556ba9..6c37c014 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,6 +2,7 @@ "root": true, "extends": [ "eslint:recommended", + "prettier", "plugin:@typescript-eslint/recommended-type-checked" ], "parser": "@typescript-eslint/parser", @@ -16,6 +17,7 @@ "no-alert": "warn", "no-empty": [ "warn", { "allowEmptyCatch": true } ], "no-eval": "error", + "no-console": "off", "@typescript-eslint/comma-dangle": [ "warn", { diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index b2fc78ad..fc14ea75 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -15,16 +15,16 @@ jobs: with: fetch-depth: 0 # Fetch all history for all branches and tags - - name: Install pnpm + - name: Install pnpm@10.12.4 uses: pnpm/action-setup@v2 with: - version: 8 + version: 10.12.4 run_install: false - - name: Use Node.js 20 + - name: Use Node.js 20.16.0 uses: actions/setup-node@v3 with: - node-version: 20 + node-version: 20.16.0 cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/dependabot-push.yml b/.github/workflows/dependabot-push.yml index 156586f9..9e9d16ae 100644 --- a/.github/workflows/dependabot-push.yml +++ b/.github/workflows/dependabot-push.yml @@ -26,7 +26,7 @@ jobs: # Install pnpm - name: Install pnpm - run: npm install -g pnpm + run: npm install -g pnpm@10.12.4 # Install dependencies - name: Install dependencies diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index 4b02130a..3b4cfe91 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -22,7 +22,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: 8 + version: 10.12.4 run_install: false - name: Use Node.js 20 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..d50617da --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": false, + "trailingComma": "all", + "printWidth": 150, + "tabWidth": 2, + "useTabs": false +} diff --git a/experiment.md b/experiment.md new file mode 100644 index 00000000..8b74a51a --- /dev/null +++ b/experiment.md @@ -0,0 +1,32 @@ +## Changed Elements React Experiment - Direct Comparison Workflow + +### HYPOTHESIS: +If we use changeset group processing without processing the changed elements, then the result of the direct processing will be produced faster and will resemble GitHub's diff functionality by displaying a flat list of changes. + +### REASON FOR EXPERIMENT +Changed elements undergo extensive processing to ensure a presentation-based summary of changes in an iModel. We aim to understand how a feature like Version Comparison would operate using raw changeset group results instead of presentation ruleset-based property path traversal. We expect faster loading times for Direct Comparison due to reduced processing, while still providing valuable output to the user. + +### EXPERIMENT +We conducted multiple experiments to confirm our hypothesis. The steps were: + +1. Run the Version Compare V2 workflow (Post/Get/Display) for an iModel with an unprocessed job range. Record the time until the user can interact with the job-related information on the UI. +2. Run the experimental Direct Comparison on the same unprocessed job version. Record the time until the user can interact with the job-related information on the UI. + +#### Results Table +We tested across three different iModels of varying sizes in the DEV region to draw better conclusions. + +| Itwin | IModel | Number Of Changeset Processed (V2 / Direct Comparison) | V2 Processing Time till interaction in UI (ms) | V2 Number of Changed Elements Found | Direct Comparison Processing Time till interaction in UI (ms) | Direct Comparison Number of Changed Elements Found | % diff between V2 and Direct Processing | +| -- | -- | -- | -- | -- | -- | -- | -- | +| 1036c64d-7fbe-47fd-b03c-4ed7ad7fc829 | c87854bc-1197-4ed9-8d3d-ad9cb5fd1347 | 12 | 22133 | 5342 | 6536 | 28039 | 108.807% | +| 1036c64d-7fbe-47fd-b03c-4ed7ad7fc829 | e657e0d6-fad1-4971-9c22-459bd400534b | 524 | 185067 | 109474 | 71375 | 314907 | 88.6688% | +| 1036c64d-7fbe-47fd-b03c-4ed7ad7fc829 | b8571aeb-dc0b-405f-bf6b-42401af40dd1 | 23 | 109007 | 128803 | 26017 | 22348 | 122.926% | + +### RESULTS SUMMARY +The most salient findings of our testing: + +1. On iModels with a vast amount of data to process, Direct Comparison is on average 106.8 % faster than V2 Comparison. +2. The UI has more elements to process with the Direct Comparison workflow than with the V2 workflow. +3. The larger the IModel/changeset range. The v2 processing is faster due to multiple agents used for processing. + +### CONCLUSION +This experiment proved that the Direct Comparison workflow is viable and may be preferable in some situations for larger iModels due to its processing speed. Direct Comparison may be an efficacious solution to long waiting times if the user does not require the full information provided by property traversal. diff --git a/package.json b/package.json index c3109d77..d93003fe 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "test:components": "npm test --prefix packages/changed-elements-react", "cover": "run-p --silent cover:*", "cover:components": "npm run test:cover --prefix packages/changed-elements-react", - "lint": "eslint '**/*.{ts,tsx}'", + "lint": "eslint ./packages/**/src/**/*.{ts,tsx}", "typecheck": "run-p --silent typecheck:*", "typecheck:components": "npm run typecheck --prefix packages/changed-elements-react", "typecheck:backend": "npm run typecheck --prefix packages/test-app-backend", @@ -16,7 +16,7 @@ "check": "changeset status" }, "engines": { - "pnpm": ">=8", + "pnpm": ">=10", "npm": "<0", "node": ">=20" }, @@ -43,6 +43,16 @@ "axios@<1.8.2": ">=1.8.2", "dompurify@<3.2.4": ">=3.2.4", "esbuild@<=0.24.2": ">=0.25.0" - } + }, + "onlyBuiltDependencies": [ + "@bentley/imodeljs-native", + "@parcel/watcher", + "@swc/core", + "esbuild", + "protobufjs" + ] + }, + "devDependencies": { + "eslint-config-prettier": "^10.1.5" } } diff --git a/packages/changed-elements-react/public/locales/en/VersionCompare.json b/packages/changed-elements-react/public/locales/en/VersionCompare.json index f53c8453..7c40e1ca 100644 --- a/packages/changed-elements-react/public/locales/en/VersionCompare.json +++ b/packages/changed-elements-react/public/locales/en/VersionCompare.json @@ -46,6 +46,7 @@ "searchResults": "Search Results", "removed": "Removed", "modified": "Modified", + "driven": "Driven by another change", "loading": "Loading...", "versions": "Versions", "version": "Version", @@ -153,8 +154,10 @@ "hiddenProperty": "Hidden Properties", "placement": "Placement", "indirect": "Indirect", + "driven": "Driven", "modifiedIndirectly": "Children modified", "modelHasChanges": "Model has changed elements", + "modelHasDrivenChanges": "Model was changed indirectly", "childrenModified": "Child elements were modified", "childrenChanges": "Children changes", "childrenAdded": "Children were added", diff --git a/packages/changed-elements-react/src/NamedVersionSelector/useComparisonJobs.ts b/packages/changed-elements-react/src/NamedVersionSelector/useComparisonJobs.ts index ac0d4770..71dcf8cc 100644 --- a/packages/changed-elements-react/src/NamedVersionSelector/useComparisonJobs.ts +++ b/packages/changed-elements-react/src/NamedVersionSelector/useComparisonJobs.ts @@ -87,7 +87,6 @@ export function useComparisonJobs(args: UseComparisonJobsArgs): UseComparisonJob job, watchJob: async function* (pollingIntervalMs: number, signal?: AbortSignal) { signal?.throwIfAborted(); - while (job.status === "Queued" || job.status === "Started") { await new Promise((resolve) => setTimeout(resolve, pollingIntervalMs)); const comparisonJob = await getComparisonJob({ diff --git a/packages/changed-elements-react/src/api/ChangedECInstanceCache.ts b/packages/changed-elements-react/src/api/ChangedECInstanceCache.ts new file mode 100644 index 00000000..c6020b13 --- /dev/null +++ b/packages/changed-elements-react/src/api/ChangedECInstanceCache.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +import { Id64String } from "@itwin/core-bentley"; +import { ChangedECInstance } from "./VersionCompare.js"; +import { ChangedElementEntry } from "./ChangedElementEntryCache.js"; + +/** + * Used to maintain the ChangedECInstances when using a custom changesProvider + * Useful to correlate the ChangedElementEntry with the ChangedECInstance + */ +export class ChangedECInstanceCache { + private readonly _cache: Map; + + constructor() { + this._cache = new Map(); + } + + /** + * Creates a key for the ChangedECInstance based on its Id and Class Id + * @param instance ChangedECInstance to create a key for + * @returns Key string for the ChangedECInstance in the cache, formatted as "instanceId:classId" + */ + private _getKey(instance: ChangedECInstance): string { + return `${instance.ECInstanceId}:${instance.ECClassId}`; + } + + /** + * Initializes the cache with the given ChangedECInstances + * The cache will be cleared before adding the instances + * @param instances + */ + public initialize(instances: ChangedECInstance[]): void { + this._cache.clear(); + for (const instance of instances) { + this._cache.set(this._getKey(instance), instance); + } + } + + /** + * Add instance to the cache + * @param instance ChangedECInstance to add to the cache + */ + public add(instance: ChangedECInstance): void { + this._cache.set(this._getKey(instance), instance); + } + + /** + * Gets the ChangedECInstance from the cache based on the instanceId and classId + * @param instanceId Id of the instance to get + * @param classId Class Id of the instance to get + * @returns ChangedECInstance if found, undefined otherwise + */ + public get(instanceId: Id64String, classId: Id64String): ChangedECInstance | undefined { + const key = `${instanceId}:${classId}`; + return this._cache.get(key); + } + + /** + * Returns whether the cache contains the ChangedECInstance with the given instanceId and classId + * @param instanceId Id of the instance to check + * @param classId Class Id of the instance to check + * @returns true if the cache contains the instance, false otherwise + */ + public has(instanceId: Id64String, classId: Id64String): boolean { + const key = `${instanceId}:${classId}`; + return this._cache.has(key); + } + + /** + * Similar to get, but uses the ChangedElementEntry to find the instance + * @param entry ChangedElementEntry to use for finding the instance + * @returns ChangedECInstance if found, undefined otherwise + */ + public getFromEntry(entry: ChangedElementEntry): ChangedECInstance | undefined { + return this.get(entry.id, entry.classId); + } + + /** + * Returns all ChangedECInstances that are present in the cache that match the entries + * @param entries + * @returns + */ + public mapFromEntries(entries: ChangedElementEntry[]): ChangedECInstance[] { + const instances: ChangedECInstance[] = []; + for (const entry of entries) { + const instance = this.get(entry.id, entry.classId); + if (instance) { + instances.push(instance); + } + } + return instances; + } + + /** + * Clears the cache of all ChangedECInstances + */ + public clear(): void { + this._cache.clear(); + } +} diff --git a/packages/changed-elements-react/src/api/ChangedElementEntryCache.ts b/packages/changed-elements-react/src/api/ChangedElementEntryCache.ts index 158c820f..3a83caf7 100644 --- a/packages/changed-elements-react/src/api/ChangedElementEntryCache.ts +++ b/packages/changed-elements-react/src/api/ChangedElementEntryCache.ts @@ -604,20 +604,16 @@ export class ChangedElementEntryCache { .map((elem: ChangedElementEntry) => elem.id); // Find top parents - const numTopParentQueries = - (currentEntryIds.length + targetEntryIds.length) / - this._findTopParentChunkSize + - 1; - this._setCurrentLoadingMessage("msg_findingParents", numTopParentQueries); this._progressCoordinator?.updateProgress(VersionCompareProgressStage.FindParents); - const currentTopParents = await this._findTopParents( + const currentTopParents = !this._manager.skipParentChildRelationships ? await this._findTopParents( this._currentIModel, currentEntryIds, - ); - const targetTopParents = await this._findTopParents( + ) : currentEntryIds; + + const targetTopParents = !this._manager.skipParentChildRelationships ? await this._findTopParents( this._targetIModel, targetEntryIds, - ); + ): targetEntryIds; // Find which parents require querying and which ones are available in entries const unchangedCurrentTopParents = []; @@ -722,7 +718,7 @@ export class ChangedElementEntryCache { this._progressCoordinator?.updateProgress(VersionCompareProgressStage.FindChildren, 0); // Load child elements of the root nodes if we are not using fast parent loading - if (this._childrenCache && !VersionCompare.manager?.wantFastParentLoad) { + if (this._childrenCache && !VersionCompare.manager?.wantFastParentLoad && !this._manager.skipParentChildRelationships) { // Set update function for UI updates this._childrenCache.updateFunction = (percent: number) => { this._progressCoordinator?.addProgress(VersionCompareProgressStage.FindChildren, percent); @@ -757,7 +753,8 @@ export class ChangedElementEntryCache { this._overrideEntriesInCache(finalEntries); // Go through all our entries and use the top parent information // to create the children arrays of top parents - this._findChildrenOfTopParents(); + if (!this._manager.skipParentChildRelationships) + this._findChildrenOfTopParents(); // Create the data provider and load the changed model nodes if (this._uiDataProvider === undefined) { diff --git a/packages/changed-elements-react/src/api/ChangedElementsChildrenCache.ts b/packages/changed-elements-react/src/api/ChangedElementsChildrenCache.ts index f9ba587d..5dbaf884 100644 --- a/packages/changed-elements-react/src/api/ChangedElementsChildrenCache.ts +++ b/packages/changed-elements-react/src/api/ChangedElementsChildrenCache.ts @@ -9,6 +9,7 @@ import { IModelConnection } from "@itwin/core-frontend"; import { ChangedElementDataCache } from "./ChangedElementDataCache.js"; import { ChangeElementType, type ChangedElement, type ChangedElementEntry } from "./ChangedElementEntryCache.js"; import type { ChangedElementQueryData } from "./ElementQueries.js"; +import { VersionCompare } from "./VersionCompare.js"; interface ParentChildData { directChildren: ChangedElementQueryData[]; @@ -115,7 +116,7 @@ export class ChangedElementsChildrenCache extends ChangedElementDataCache { map: Map, elementIds: string[], ) => { - if (elementIds.length === 0) { + if (elementIds.length === 0 || VersionCompare.manager?.skipParentChildRelationships) { return; } diff --git a/packages/changed-elements-react/src/api/ChangedElementsManager.ts b/packages/changed-elements-react/src/api/ChangedElementsManager.ts index 0a8b1759..0a678582 100644 --- a/packages/changed-elements-react/src/api/ChangedElementsManager.ts +++ b/packages/changed-elements-react/src/api/ChangedElementsManager.ts @@ -6,7 +6,7 @@ import { BeEvent, DbOpcode } from "@itwin/core-bentley"; import { QueryBinder, QueryRowFormat, TypeOfChange, type ChangedElements } from "@itwin/core-common"; import { IModelApp, IModelConnection, ModelState } from "@itwin/core-frontend"; -import { ChangedElementEntryCache, type ChangedElement, type Checksums } from "./ChangedElementEntryCache.js"; +import { ChangedElementEntry, ChangedElementEntryCache, type ChangedElement, type Checksums } from "./ChangedElementEntryCache.js"; import { ChangedElementsChildrenCache } from "./ChangedElementsChildrenCache.js"; import { ChangedElementsLabelsCache } from "./ChangedElementsLabelCache.js"; import { VersionCompareManager, VersionCompareProgressStage } from "./VersionCompareManager.js"; @@ -294,6 +294,8 @@ export const extractProperties = ( ): Map | undefined => { if (changeset.opcodes[index] === DbOpcode.Update && changeset.properties) { const map: Map = new Map(); + if (changeset.properties[index] === undefined) + return undefined; for ( let propIndex = 0; propIndex < changeset.properties[index].length; @@ -378,6 +380,16 @@ export class ChangedElementsManager { public modelToParentModelMap: Map | undefined; + public elementToDrivenElement: Map = new Map(); + public setElementToDrivenElementMap(_map: Map) { + this.elementToDrivenElement = _map; + } + + public elementDrivesElement: Map = new Map(); + public setElementDrivesElementMap(_map: Map) { + this.elementDrivesElement = _map; + } + /** * * @returns Set of parent model ids used to parent elements in UI tree @@ -534,6 +546,53 @@ export class ChangedElementsManager { return models; }; + private _getDirectlyDrivenElements = (entries: ChangedElementEntry[]): ChangedElementEntry[] => { + const relevantChangedElements = []; + for (const entry of entries) { + const key = `${entry.id}`; + const drivenElements = this.elementDrivesElement.get(key); + if (drivenElements) { + // TODO: Seems like everything is using instance id without class id, that seems wrong... + for (const instanceId of drivenElements) { + const changedElement = this._entryCache.changedElementEntries.get(instanceId); + if (changedElement) { + relevantChangedElements.push(changedElement); + } + } + } + } + return relevantChangedElements; + } + + /** + * Returns any elements (recursively) that were changed by direct changes of the given instances with multiple jumps + * @param entries + * @returns + */ + public getDrivenElementsRecursive = (entries: ChangedElementEntry[]): ChangedElementEntry[] => { + const relevantChangedElements = []; + let currentElements = entries; + while (currentElements.length > 0) { + const drivenElements = this._getDirectlyDrivenElements(currentElements); + if (drivenElements.length === 0) { + break; + } + currentElements = drivenElements; + relevantChangedElements.push(...drivenElements); + } + + return relevantChangedElements; + } + + /** + * Returns any elements that were changed by direct changes of the given instances with multiple jumps + * @param entries + * @returns + */ + public getDrivenElements = (entries: ChangedElementEntry[]): ChangedElementEntry[] => { + return this._getDirectlyDrivenElements(entries); + } + /** * Get props for all elements and get changed models. Later on this data will be provided in the Changed Elements Service * @param currentIModel Current IModelConnection @@ -856,8 +915,10 @@ export class ChangedElementsManager { cleanMergedElements(this._changedElements); } - // Fix missing model Ids before we filter by model class - await this._fixModelIds(currentIModel, targetIModel); + if (!this._manager.skipParentChildRelationships) { + // Fix missing model Ids before we filter by model class + await this._fixModelIds(currentIModel, targetIModel); + } // Filter out changed elements that we don't care about given the model classes if (wantedModelClasses) { @@ -869,12 +930,13 @@ export class ChangedElementsManager { } // Filter by spatial elements if we want - if (filterSpatial) { + if (filterSpatial && !this._manager.skipParentChildRelationships) { const geom3dId = await this._getGeometricElement3dClassId(currentIModel); if (!geom3dId) { return; } + // Filter out elements that are not GeometricElement3d const ecsql = "SELECT SourceECInstanceId FROM meta.ClasshasAllBaseClasses WHERE TargetECInstanceId = " + geom3dId; @@ -894,8 +956,10 @@ export class ChangedElementsManager { } } - // Find proper models to display elements under - await this._findParentModels(currentIModel, targetIModel); + if (!this._manager.skipParentChildRelationships) { + // Find proper models to display elements under + await this._findParentModels(currentIModel, targetIModel); + } } /** @@ -1185,29 +1249,15 @@ export class ChangedElementsManager { IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.msg_computingChangedModels"), ); } - progressCoordinator?.updateProgress(VersionCompareProgressStage.ComputeChangedModels); + if (!this._manager.skipParentChildRelationships) { + progressCoordinator?.updateProgress(VersionCompareProgressStage.ComputeChangedModels); - // Find changed models - this._changedModels = await this.findChangedModels( - currentIModel, - targetIModel, - forward ?? false, - progressCoordinator, - progressLoadingEvent, - ); + // Find changed models + this._changedModels = await this.findChangedModels(currentIModel, targetIModel, forward ?? false, progressCoordinator, progressLoadingEvent); - if (progressLoadingEvent) { - progressLoadingEvent.raiseEvent( - IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.msg_computingUnchangedModels"), - ); + // Find unchanged models + this._unchangedModels = await this.findUnchangedModels(currentIModel, this._changedModels); } - progressCoordinator?.updateProgress(VersionCompareProgressStage.ComputeChangedModels, 100); - - // Find unchanged models - this._unchangedModels = await this.findUnchangedModels( - currentIModel, - this._changedModels, - ); await this.generateEntries(currentIModel, targetIModel, progressCoordinator); } diff --git a/packages/changed-elements-react/src/api/ChangesTooltipProvider.ts b/packages/changed-elements-react/src/api/ChangesTooltipProvider.ts index 53148ad4..e259dc82 100644 --- a/packages/changed-elements-react/src/api/ChangesTooltipProvider.ts +++ b/packages/changed-elements-react/src/api/ChangesTooltipProvider.ts @@ -21,7 +21,7 @@ import { VersionCompareManager } from "./VersionCompareManager.js"; const appendChangeType = ( message: string, type: number, - toc: TypeOfChange, + toc: number, localeStr: string, ) => { if ((type & toc) !== 0) { @@ -67,7 +67,7 @@ const appendChangeTypes = (message: string, typeOfChange: number) => { TypeOfChange.Hidden, "hiddenProperty", ); - return message.substr(0, message.length - 2); + return message.substring(0, message.length - 2); }; /** diff --git a/packages/changed-elements-react/src/api/ElementQueries.ts b/packages/changed-elements-react/src/api/ElementQueries.ts index f3e7b63d..a5735323 100644 --- a/packages/changed-elements-react/src/api/ElementQueries.ts +++ b/packages/changed-elements-react/src/api/ElementQueries.ts @@ -9,6 +9,7 @@ import { KeySet, type InstanceKey, type Key } from "@itwin/presentation-common"; import { Presentation } from "@itwin/presentation-frontend"; import { ChangeElementType, type ChangedElementEntry } from "./ChangedElementEntryCache.js"; +import { VersionCompare } from "./VersionCompare.js"; /** * Interface for query data @@ -58,7 +59,7 @@ export const generateEntryFromQueryData = ( existingEntry && existingEntry.opcode ? existingEntry.opcode : DbOpcode.Update, - type: existingEntry?.type ?? 0, // TODO: Do we need to mark these as indirect ? + type: existingEntry?.type ?? 0, indirect: existingEntry === undefined, foundInCurrent, loaded: false, @@ -108,7 +109,7 @@ export const queryEntryData = async ( classFullName: (row.className as string).replace(".", ":"), modelId: row.model.id, classId: row.classId, - parent: row.parent ? row.parent.id : undefined, + parent: row.parent && !VersionCompare.manager?.skipParentChildRelationships ? row.parent.id : undefined, }; result.push(data); } diff --git a/packages/changed-elements-react/src/api/VersionCompare.ts b/packages/changed-elements-react/src/api/VersionCompare.ts index a3b62898..5382bdee 100644 --- a/packages/changed-elements-react/src/api/VersionCompare.ts +++ b/packages/changed-elements-react/src/api/VersionCompare.ts @@ -3,9 +3,9 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import { getClassName } from "@itwin/appui-abstract"; -import type { AccessToken } from "@itwin/core-bentley"; -import type { ModelProps } from "@itwin/core-common"; -import { IModelApp, IModelConnection, ViewState } from "@itwin/core-frontend"; +import type { AccessToken, Id64String } from "@itwin/core-bentley"; +import type { ModelProps, ChangesetIdWithIndex, TypeOfChange } from "@itwin/core-common"; +import { FeatureSymbology, IModelApp, IModelConnection, ViewState } from "@itwin/core-frontend"; import { KeySet } from "@itwin/presentation-common"; import { ChangedElementsApiClient } from "./ChangedElementsApiClient.js"; @@ -36,6 +36,43 @@ interface IVersionCompareClientFactory { createChangedElementsClient: () => ChangedElementsClientBase; } +export interface RelationshipClassWithDirection { + className: string; + direction: "forward" | "backward"; +} + +export interface ElementWithRelationship { + id: Id64String; + relationship: RelationshipClassWithDirection; +} + +export interface ComparisonMetadata { + drivenBy?: ElementWithRelationship[]; + drives?: ElementWithRelationship[]; + type?: TypeOfChange; +} + +/** + * TODO: This should be moved to common package + */ +export interface ChangedECInstance { + ECInstanceId: Id64String; + ECClassId?: Id64String; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + $meta?: any; + // Added by enricher + $comparison?: ComparisonMetadata; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +/** + * Expected output of a changeset processor function. + */ +export interface ChangesProviderOutput { + changedInstances: ChangedECInstance[]; +} + export interface VersionCompareOptions { /** * Base URL for iTwin Platform Changed Elements API. @@ -91,6 +128,56 @@ export interface VersionCompareOptions { getAccessToken?: () => Promise; createVisualizationHandler: (manager: VersionCompareManager) => VisualizationHandler; + + /** + * If provided, this function will be called to obtain the changeset data instead of using changed elements API + * This allows a consumer application to use its own changeset processing logic. + * @param startChangeset Start changeset to compare (oldest) + * @param endChangeset End changeset to compare (newest) + * @param iModelConnection iModel connection to use + * @returns a promise that should resolve to an object containing an array of ChangedECInstance + */ + changesProvider?: (startChangeset: ChangesetIdWithIndex, endChangeset: ChangesetIdWithIndex, iModelConnection: IModelConnection) => Promise; + + /** + * Allows the application to provide a color override for changed ec instances. + * This is called each time the viewport is updated with new changed elements to be visualized. + * (e.g. when the user toggles filters, inspects a model or an element's children, etc.) + * + * Example: + * + * ```typescript + * (instances, overrides) => { + * for (const instance of instances) { + * // The ChangedECInstance will contain everything that was provided by `changesProvider` function in the callback + * if (instance.type === "Update") { + * // Override color for updated elements to a light blue color + * overrides.setFeatureOverride(instance.id, new FeatureAppearance({ color: ColorDef.from(155, 155, 255) })); + * } + * } + * ``` + * + * This is called *after* the `colorOverrideProvider` from `VersionCompareTiles` is called, so it can be used to override + * default version compare behavior / coloring. + * + * Note: The way version compare achieves coloring unchanged elements is by using `FeatureSymbology.Overrides` and `setDefaultOverrides` + * Clearing the overrides when received from this function may result in the emphasis of unchanged elements to be lost + * It is recommended to only override colors for changed instances, and not clear all the overrides. + * + * @param visibleInstances ChangedECInstances to override colors for, will match the instances provided by `changesProvider`. Contains only the instances that are visible due to filters in the UI + * @param hiddenInstances ChangedECInstances to override colors for, will match the instances provided by `changesProvider`. Contains only the instances that are not visible due to filters in the UI + * @param overrides FeatureSymbology.Overrides to apply the color overrides to. + */ + colorOverrideProvider?: (visibleInstances: ChangedECInstance[], hiddenInstances: ChangedECInstance[], overrides: FeatureSymbology.Overrides) => void; + + /** + * Handler called when the user selects a changed instance (or multiple) in the changed elements widget. + * This is used to allow the application to perform some custom action when the user selects an instance that contains changes. + * This call won't be awaited by the UI. + * @param instance + * @returns + */ + onInstancesSelected?: (instances: ChangedECInstance[]) => Promise; } /** Maintains all version compare related data for the applications. */ @@ -98,6 +185,11 @@ export class VersionCompare { private static _manager: VersionCompareManager | undefined; private static _getAccessToken?: () => Promise; + private static _changesProvider?: (startChangeset: ChangesetIdWithIndex, endChangeset: ChangesetIdWithIndex, iModelConnection: IModelConnection) => Promise<{ changedInstances: ChangedECInstance[]; }>; + public static get changesProvider() { + return VersionCompare._changesProvider; + } + public static get logCategory(): string { return "VersionCompare"; } @@ -138,7 +230,8 @@ export class VersionCompare { // Initialize manager VersionCompare._manager = new VersionCompareManager(options); - // get the access token + // Store options / callbacks + VersionCompare._changesProvider = options.changesProvider; VersionCompare._getAccessToken = options.getAccessToken ?? IModelApp.getAccessToken; if (options.changedElementsApiBaseUrl) { diff --git a/packages/changed-elements-react/src/api/VersionCompareManager.ts b/packages/changed-elements-react/src/api/VersionCompareManager.ts index 4eb92ada..4e67cefa 100644 --- a/packages/changed-elements-react/src/api/VersionCompareManager.ts +++ b/packages/changed-elements-react/src/api/VersionCompareManager.ts @@ -5,7 +5,7 @@ import { BeEvent, Logger } from "@itwin/core-bentley"; import { IModelVersion, type ChangedElements } from "@itwin/core-common"; import { - CheckpointConnection, GeometricModel2dState, GeometricModel3dState, IModelApp, IModelConnection, NotifyMessageDetails, + CheckpointConnection, FeatureSymbology, GeometricModel2dState, GeometricModel3dState, IModelApp, IModelConnection, NotifyMessageDetails, OutputMessagePriority } from "@itwin/core-frontend"; import { KeySet } from "@itwin/presentation-common"; @@ -18,7 +18,9 @@ import { ChangesTooltipProvider } from "./ChangesTooltipProvider.js"; import { VersionCompareUtils, VersionCompareVerboseMessages } from "./VerboseMessages.js"; import { VersionCompare, type VersionCompareFeatureTracking, type VersionCompareOptions } from "./VersionCompare.js"; import { VisualizationHandler } from "./VisualizationHandler.js"; +import { extractDrivenByInstances, extractDrivesInstances, transformToAPIChangedElements } from "../utils/utils.js"; import { ProgressCoordinator } from "../widgets/ProgressCoordinator.js"; +import { ChangedECInstanceCache } from "./ChangedECInstanceCache.js"; const LOGGER_CATEGORY = "Version-Compare"; @@ -46,6 +48,8 @@ export enum VersionCompareProgressStage { export class VersionCompareManager { /** Changed Elements Manager responsible for maintaining the elements obtained from the service */ public changedElementsManager: ChangedElementsManager; + /** ChangedECInstance cache only used when using changesProvider options */ + public changedECInstanceCache: ChangedECInstanceCache; private progressCoordinator: ProgressCoordinator; @@ -53,6 +57,7 @@ export class VersionCompareManager { private _hasTypeOfChange = false; private _hasPropertiesForFiltering = false; private _hasParentIds = false; + private _skipParentChildRelationships = false; // define stage and order private weights: Record = { @@ -79,6 +84,8 @@ export class VersionCompareManager { this.changedElementsManager = new ChangedElementsManager(this); + this.changedECInstanceCache = new ChangedECInstanceCache(); + // Tooltip provider for type of change if (options.wantTooltipAugment) { const tooltipProvider = new ChangesTooltipProvider(this); @@ -103,7 +110,11 @@ export class VersionCompareManager { } public get filterSpatial(): boolean { - return this.options.filterSpatial ?? true; + return this.options.filterSpatial ?? this.options.changesProvider === undefined; + } + + public get skipParentChildRelationships(): boolean { + return this._skipParentChildRelationships; } public get wantPropertyFiltering(): boolean { @@ -238,6 +249,24 @@ export class VersionCompareManager { return filteredResults; }; + /** + * Helper function that will use the provided `colorOverrideProvider` initialization option + * @returns + */ + public getColorOverrideProvider = (): ((visibleEntries: ChangedElementEntry[], hiddenEntries: ChangedElementEntry[], overrides: FeatureSymbology.Overrides) => void) | undefined => { + const customProvider = this.options.colorOverrideProvider; + if (!customProvider) { + return undefined; + } + + // Wrap the option function to provide the visible and hidden ChangedECInstance arrays instead of changed-elements-react specific ChangedElementEntry[] + return (visibleEntries: ChangedElementEntry[], hiddenEntries: ChangedElementEntry[], overrides: FeatureSymbology.Overrides) => { + const visibleInstances = this.changedECInstanceCache.mapFromEntries(visibleEntries); + const hiddenInstances = this.changedECInstanceCache.mapFromEntries(hiddenEntries); + customProvider(visibleInstances, hiddenInstances, overrides); + }; + } + /** * Request changed elements between two versions given from the Changed Elements Service. * @param currentIModel Current IModelConnection @@ -293,15 +322,15 @@ export class VersionCompareManager { throw new Error("Cannot compare to a version if it doesn't contain a changeset Id"); } + if (!this._currentIModel.iModelId || !this._currentIModel.iTwinId) { + throw new Error("Cannot compare with an iModel lacking iModelId or iTwinId (aka projectId)"); + } + // Setup visualization handler this._initializeVisualizationHandler(); // Raise event that comparison is starting this.versionCompareStarting.raiseEvent(); - if (!this._currentIModel.iModelId || !this._currentIModel.iTwinId) { - throw new Error("Cannot compare with an iModel lacking iModelId or iTwinId (aka projectId)"); - } - this.loadingProgressEvent.raiseEvent( IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.msg_openingTarget"), ); @@ -408,6 +437,151 @@ export class VersionCompareManager { return success; } + /** + * Uses the changeset processor to get the changed elements between two versions. + * @param currentIModel + * @param currentVersion + * @param targetVersion + * @returns + */ + public async startDirectComparison( + currentIModel: IModelConnection, + currentVersion: NamedVersion, + targetVersion: NamedVersion): Promise { + this._currentIModel = currentIModel; + let success = true; + this._skipParentChildRelationships = true; + try { + const changesetProcessor = VersionCompare.changesProvider; + if (!changesetProcessor) { + throw new Error("Cannot do direct comparison without a changeset processor"); + } + + // Setup visualization handler + this._initializeVisualizationHandler(); + // Raise event that comparison is starting + this.versionCompareStarting.raiseEvent(); + + if (!this._currentIModel.iModelId || !this._currentIModel.iTwinId) { + throw new Error("Cannot compare with an iModel lacking iModelId or iTwinId (aka projectId)"); + } + + this.loadingProgressEvent.raiseEvent( + IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.msg_openingTarget"), + ); + + // Open the target version IModel + const changesetId = targetVersion.changesetId; + this._targetIModel = await CheckpointConnection.openRemote( + this._currentIModel.iTwinId, + this._currentIModel.iModelId, + IModelVersion.asOfChangeSet(changesetId!), + ); + const processorResults = await changesetProcessor( + { id: targetVersion.changesetId ?? "", index: targetVersion.changesetIndex ?? 0 }, + { + id: currentVersion.changesetId ?? "", index: currentVersion.changesetIndex ?? 0, + }, currentIModel); + const changedElements = [transformToAPIChangedElements(processorResults.changedInstances)]; + if (!targetVersion.changesetId) { + throw new Error("Cannot compare to a version if it doesn't contain a changeset Id"); + } + + // Initialize ChangedECInstance cache for correlating entries + this.changedECInstanceCache.initialize(processorResults.changedInstances); + + // Keep metadata around for UI uses and other queries + this.currentVersion = currentVersion; + this.targetVersion = targetVersion; + + this.loadingProgressEvent.raiseEvent( + IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.msg_getChangedElements"), + ); + + this.loadingProgressEvent.raiseEvent( + IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.msg_initializingComparison"), + ); + + let wantedModelClasses = [ + GeometricModel2dState.classFullName, + GeometricModel3dState.classFullName, + ]; + if (this.options.wantedModelClasses) { + wantedModelClasses = this.options.wantedModelClasses; + } + let filteredChangedElements = changedElements; + if (this.ignoredElementIds !== undefined) { + filteredChangedElements = this._filterIgnoredElementsFromChangesets(changedElements); + } + await this.changedElementsManager.initialize( + this._currentIModel, + this._targetIModel, + filteredChangedElements, + this.wantAllModels ? undefined : wantedModelClasses, + false, + this.filterSpatial, + undefined, + this.loadingProgressEvent, + ); + // Add source of changes for driven and domain specific element changes + this.changedElementsManager.setElementToDrivenElementMap(extractDrivenByInstances(processorResults.changedInstances)); + this.changedElementsManager.setElementDrivesElementMap(extractDrivesInstances(processorResults.changedInstances)); + const changedElementEntries = this.changedElementsManager.entryCache.getAll(); + + // We have parent Ids available if any entries contain undefined parent data + this._hasParentIds = false; + // We have type of change available if any of the entries has a valid type of change value + this._hasTypeOfChange = changedElementEntries.some((entry) => entry.type !== 0); + // We have property filtering available if any of the entries has a valid array of changed properties + this._hasPropertiesForFiltering = changedElementEntries.some( + (entry) => entry.properties !== undefined && entry.properties.size !== 0, + ); + + // Get the entries + this.loadingProgressEvent.raiseEvent( + IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.msg_findingAssemblies"), + ); + await this.changedElementsManager.entryCache.initialLoad(changedElementEntries.map((entry) => entry.id)); //, true); + + // Reset the select tool to allow external iModels to be located + await IModelApp.toolAdmin.startDefaultTool(); + + // Enable visualization of version comparison + await this.enableVisualization(false); + + // Raise event + this.versionCompareStarted.raiseEvent(this._currentIModel, this._targetIModel, changedElementEntries); + VersionCompareUtils.outputVerbose(VersionCompareVerboseMessages.versionCompareManagerStartedComparison); + VersionCompare.manager?.featureTracking?.trackVersionSelectorV2Usage(); + } catch (ex) { + // Let user know comparison failed - TODO: Give better errors + const briefError = IModelApp.localization.getLocalizedString( + "VersionCompare:versionCompare.error_versionCompare", + ); + const detailed = IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error_cantStart"); + let errorMessage = "Unknown Error"; + if (ex instanceof Error) { + errorMessage = ex.message; + } else if (typeof ex === "string") { + errorMessage = ex; + } + + IModelApp.notifications.outputMessage( + new NotifyMessageDetails(OutputMessagePriority.Error, briefError, `${detailed}: ${errorMessage}`), + ); + + this.versionCompareStartFailed.raiseEvent(); + this._currentIModel = undefined; + this._targetIModel = undefined; + success = false; + this._skipParentChildRelationships = false; + VersionCompareUtils.outputVerbose(VersionCompareVerboseMessages.versionCompareManagerErrorStarting); + + await this.stopComparison(); + } + + return success; + } /** * Starts comparison by opening a new iModelConnection and setting up the store. * @param currentIModel Current IModelConnection to be used to compare against @@ -554,7 +728,6 @@ export class VersionCompareManager { public async stopComparison(): Promise { // Let listeners know we are cleaning up comparison this.versionCompareStopping.raiseEvent(); - try { if (this._targetIModel) { await this._targetIModel.close(); @@ -563,6 +736,7 @@ export class VersionCompareManager { } this.changedElementsManager.cleanup(); + this.changedECInstanceCache.clear(); // Reset the select tool to allow external iModels to be located await IModelApp.toolAdmin.startDefaultTool(); @@ -578,6 +752,7 @@ export class VersionCompareManager { this._currentIModel = undefined; this._targetIModel = undefined; this._isComparisonStarted = false; + this._skipParentChildRelationships = false; // Clean-up visualization handler await this._visualizationHandler?.cleanUp(); diff --git a/packages/changed-elements-react/src/api/VersionCompareTiles.ts b/packages/changed-elements-react/src/api/VersionCompareTiles.ts index b8fb98e7..f8378aca 100644 --- a/packages/changed-elements-react/src/api/VersionCompareTiles.ts +++ b/packages/changed-elements-react/src/api/VersionCompareTiles.ts @@ -45,6 +45,7 @@ export interface VersionDisplayOptions { changedModels?: Set; hiddenDeletedElements?: Set; emphasized?: boolean; + colorOverrideProvider?: (visibleInstances: ChangedElementEntry[], hiddenInstances: ChangedElementEntry[], overrides: FeatureSymbology.Overrides) => void; } class Trees extends SpatialModelTileTrees { @@ -137,6 +138,7 @@ export class Provider targetIModelModels?: Set, targetIModelCategories?: Set, hiddenElems?: ChangedElementEntry[], + private _colorOverrideProvider?: (visibleEntries: ChangedElementEntry[], hiddenEntries: ChangedElementEntry[], overrides: FeatureSymbology.Overrides) => void, ) { this.iModel = iModel; this.visibleChangedElems = elems; @@ -295,6 +297,8 @@ export class Provider options, new Set([...data.deletedElementsModels, ...data.updatedElementsModels]), data.categories, + undefined, + options?.colorOverrideProvider, ); } catch (ex) { let error = "Unknown Error"; @@ -397,12 +401,11 @@ export class Provider for (const elem of updatedElems) { // Check if user is emphasizing some elements, and if so, only override said elements if (this._internalAlwaysDrawn.size === 0 || this._internalAlwaysDrawn.has(elem.id)) { - overrides.override({ - elementId: elem.id, - appearance: elem.indirect - ? updatedIndirectly - : updated, - }); + // TODO: Appropriate type of change enum + const appearance = elem.indirect + ? updatedIndirectly + : updated; + overrides.override({ elementId: elem.id, appearance: appearance }); } } @@ -415,6 +418,11 @@ export class Provider }); } } + + /** Allow apps to define their own color overrides if provided */ + if (this._colorOverrideProvider) { + this._colorOverrideProvider(this.visibleChangedElems, this.hiddenChangedElems, overrides); + } } /** Overrides the given elements to show their tiles from the secondary iModel Connection */ diff --git a/packages/changed-elements-react/src/api/VersionCompareVisualization.ts b/packages/changed-elements-react/src/api/VersionCompareVisualization.ts index 86cf39e6..39c75922 100644 --- a/packages/changed-elements-react/src/api/VersionCompareVisualization.ts +++ b/packages/changed-elements-react/src/api/VersionCompareVisualization.ts @@ -5,7 +5,7 @@ import { BeEvent, DbOpcode, type Id64String } from "@itwin/core-bentley"; import { ColorDef, EmphasizeElementsProps, Placement3d, RgbColor, type ElementProps, type GeometricElement3dProps } from "@itwin/core-common"; import { - EmphasizeElements, GeometricModelState, IModelApp, IModelConnection, MarginPercent, ScreenViewport, SpatialViewState, ViewState3d } from "@itwin/core-frontend"; + EmphasizeElements, FeatureSymbology, GeometricModelState, IModelApp, IModelConnection, MarginPercent, ScreenViewport, SpatialViewState, ViewState3d } from "@itwin/core-frontend"; import { Range3d, Transform } from "@itwin/core-geometry"; import { KeySet } from "@itwin/presentation-common"; import { HiliteSetProvider } from "@itwin/presentation-frontend"; @@ -44,6 +44,9 @@ export class VersionCompareVisualizationManager { public static colorModifiedRgb() { return new RgbColor(0, 139, 225); } + public static colorModifiedDrivenRgb() { + return new RgbColor(185, 139, 225); + } public static colorModifiedTargetRgb() { return new RgbColor(0, 200, 225); } @@ -78,6 +81,7 @@ export class VersionCompareVisualizationManager { private _unchangedModels?: Set, private _onViewChanged?: BeEvent<(args: unknown) => void>, _wantSecondaryModified?: boolean, + _colorOverrideProvider?: (visibleInstances: ChangedElementEntry[], hiddenInstances: ChangedElementEntry[], overrides: FeatureSymbology.Overrides) => void, ) { if (_onViewChanged !== undefined) { _onViewChanged.addListener(this.onViewChangedHandler); @@ -88,6 +92,7 @@ export class VersionCompareVisualizationManager { hideModified: false, wantModified: _wantSecondaryModified, emphasized: true, + colorOverrideProvider: _colorOverrideProvider, }; this._currentHiliteSetProvider = HiliteSetProvider.create({ imodel: this._viewport.iModel, @@ -599,6 +604,48 @@ export class VersionCompareVisualizationManager { viewport.synchWithView(); }; + /** + * Zooms to multiple entries + * @param iModel + * @param entries + * @returns + */ + private _zoomToElements = async (iModel: IModelConnection, elementIds: Id64String[]) => { + const viewport = IModelApp.viewManager.selectedView; + if (viewport === undefined) { + return; + } + + // Get the 3d view state to adjust for + const viewState: ViewState3d = viewport.view as ViewState3d; + // Find the range of the combined elements + const range = await this._findElementsVolume(iModel, viewState, elementIds); + if (range === undefined) { + return; + } + // Do zoom operation with a 10% margin + viewport.view.lookAtVolume(range, viewport.viewRect.aspect, { + marginPercent: new MarginPercent(0.1, 0.1, 0.1, 0.1), + }); + viewport.synchWithView(); + }; + + /** + * Zooms to the merged volume of many elements + * @param elementIds + */ + public zoomToElements = async (elementIds: Id64String[]) => { + const currentIModel = this._viewport.iModel; + // TODO: Appropriately zoom to target and current + // const targetIModel = this._targetIModel; + await this._zoomToElements( + currentIModel, + elementIds, + ); + + VersionCompareUtils.outputVerbose(VersionCompareVerboseMessages.changedElementsTreeElementClicked); + } + /** Handles zooming to element and selecting elements */ public zoomToEntry = async (entry: ChangedElementEntry) => { const currentIModel = this._viewport.iModel; diff --git a/packages/changed-elements-react/src/dialogs/PropertyLabelCache.tsx b/packages/changed-elements-react/src/dialogs/PropertyLabelCache.tsx index adeff867..10ae9b1c 100644 --- a/packages/changed-elements-react/src/dialogs/PropertyLabelCache.tsx +++ b/packages/changed-elements-react/src/dialogs/PropertyLabelCache.tsx @@ -16,8 +16,7 @@ export class PropertyLabelCache { _classId: Id64String, propertyName: string, ): string => { - // TODO: For now disregard class Id, as we need to handle it differently from the changed elements accumulation step - // return `${classId}:${propertyName}`; + // For now disregard class Id, as we need to handle it differently from the changed elements accumulation step return propertyName; }; diff --git a/packages/changed-elements-react/src/utils/utils.ts b/packages/changed-elements-react/src/utils/utils.ts index 6c1fc15c..d91f27ba 100644 --- a/packages/changed-elements-react/src/utils/utils.ts +++ b/packages/changed-elements-react/src/utils/utils.ts @@ -1,13 +1,12 @@ /*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ - -export async function* splitBeforeEach( - iterable: AsyncIterable, - selector: (value: T) => U, - markers: U[], -): AsyncGenerator { + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import { DbOpcode } from "@itwin/core-bentley"; +import { ChangedElements, TypeOfChange } from "@itwin/core-common"; +import { ChangedECInstance } from "../api/VersionCompare.js"; + +export async function* splitBeforeEach(iterable: AsyncIterable, selector: (value: T) => U, markers: U[]): AsyncGenerator { let accumulator: T[] = []; let currentMarkerIndex = 0; for await (const value of iterable) { @@ -23,7 +22,6 @@ export async function* splitBeforeEach( yield accumulator; } - export async function* flatten(iterable: AsyncIterable): AsyncGenerator { for await (const values of iterable) { for (const value of values) { @@ -56,12 +54,7 @@ export async function* skip(iterable: AsyncIterable, n: number): AsyncGene return result.value; } -export async function tryXTimes( - func: () => Promise, - attempts: number, - delayInMilliseconds: number = 5000, - signal?: AbortSignal, -): Promise { +export async function tryXTimes(func: () => Promise, attempts: number, delayInMilliseconds: number = 5000, signal?: AbortSignal): Promise { signal?.throwIfAborted(); let error: unknown = null; @@ -100,3 +93,96 @@ export const arrayToMap = (array: T[], createKey: (entry: T) => U) => { }); return newMap; }; + +/** + * @returns Empty ChangedElements object + */ +const createEmptyChangedElements = (): ChangedElements => { + return { + elements: [], + classIds: [], + modelIds: [], + opcodes: [], + type: [], + properties: [], + parentIds: [], + parentClassIds: [], + }; +}; + +/** + * Convert {@link SqliteChangeOp} string to {@link DbOpcode} number. + * + * Throws error if not a valid {@link SqliteChangeOp} string. + */ +const stringToOpcode = (operation: string): DbOpcode => { + switch (operation) { + case "Inserted": + return DbOpcode.Insert; + case "Updated": + return DbOpcode.Update; + case "Deleted": + return DbOpcode.Delete; + default: + throw new Error("Unknown opcode string"); + } +}; + +/** + * Transforms ChangedECInstance array to ChangedElements object for direct comparison + * @param instances Array of ChangedECInstance objects + * @returns ChangedElements object representing all changed elements + */ +export const transformToAPIChangedElements = (instances: ChangedECInstance[]): ChangedElements => { + const ce: ChangedElements = createEmptyChangedElements(); + const ceMap: Map = new Map(); + instances.forEach((elem) => { + if (!ceMap.has(`${elem.ECInstanceId}:${elem.ECClassId}`)) { + ceMap.set(`${elem.ECInstanceId}:${elem.ECClassId}`, elem); + } + }); + + for (const elem of ceMap.values()) { + ce.elements.push(elem.ECInstanceId); + ce.classIds.push(elem.ECClassId ?? ""); + ce.opcodes.push(stringToOpcode(elem.$meta?.op ?? "")); + ce.type.push(elem.$comparison?.type ?? TypeOfChange.NoChange); + } + + return ce; +}; + +/** + * Returns a map of ECInstanceId:ECClassId of the element that is driven by another element + * e.g. Target -> Source + * TODO: This needs to be refactored and passed by the consuming application + * @param instances + * @returns + */ +export const extractDrivenByInstances = (instances: ChangedECInstance[]): Map => { + const elementDrivenByElementMap = new Map(); + instances.forEach((elem) => { + if (elem.$comparison?.drivenBy) { + const ids = elem.$comparison.drivenBy.map((idWithRelationship: { id: string; }) => idWithRelationship.id); + elementDrivenByElementMap.set(`${elem.ECInstanceId}`, ids); + } + }); + return elementDrivenByElementMap; +} + +/** + * Returns a map of ECInstanceId:ECClassId of the element that drives another element + * TODO: This needs to be refactored and passed by the consuming application + * @param instances + * @returns + */ +export const extractDrivesInstances = (instances: ChangedECInstance[]): Map => { + const elementDrivesElementMap = new Map(); + instances.forEach((elem) => { + if (elem.$comparison?.drives) { + const ids = elem.$comparison.drives.map((idWithRelationship: { id: string; }) => idWithRelationship.id); + elementDrivesElementMap.set(`${elem.ECInstanceId}`, ids); + } + }); + return elementDrivesElementMap; +} diff --git a/packages/changed-elements-react/src/widgets/ChangedElementsInspector.scss b/packages/changed-elements-react/src/widgets/ChangedElementsInspector.scss index cf28101c..5408ebe1 100644 --- a/packages/changed-elements-react/src/widgets/ChangedElementsInspector.scss +++ b/packages/changed-elements-react/src/widgets/ChangedElementsInspector.scss @@ -311,6 +311,11 @@ border-color: RGB(0, 139, 225); } + &-driven { + @extend .change-square; + border-color: RGB(185, 139, 225); + } + &-indirect { @extend .change-square; border-color: RGB(0, 139, 225); diff --git a/packages/changed-elements-react/src/widgets/ElementsList.tsx b/packages/changed-elements-react/src/widgets/ElementsList.tsx index 34ac95da..b3518f05 100644 --- a/packages/changed-elements-react/src/widgets/ElementsList.tsx +++ b/packages/changed-elements-react/src/widgets/ElementsList.tsx @@ -138,7 +138,7 @@ export const ElementsList = forwardRef( label={item.label} isModel={item.extendedData?.isModel ?? false} opcode={item.extendedData?.element?.opcode} - type={item.extendedData?.element?.type ?? 0} + type={item.extendedData?.element?.type ?? item.extendedData?.modelChanges?.typeOfChange ?? 0} wantTypeTooltip={true} hasChildren={(0, props.hasChildren)(item)} loadingChildren={item.extendedData?.loadingChildren ?? false} diff --git a/packages/changed-elements-react/src/widgets/EnhancedElementsInspector.tsx b/packages/changed-elements-react/src/widgets/EnhancedElementsInspector.tsx index 08779d97..084e1c72 100644 --- a/packages/changed-elements-react/src/widgets/EnhancedElementsInspector.tsx +++ b/packages/changed-elements-react/src/widgets/EnhancedElementsInspector.tsx @@ -31,7 +31,6 @@ import { ElementsList } from "./ElementsList.js"; import "./ChangedElementsInspector.scss"; import { TextEx } from "../NamedVersionSelector/TextEx.js"; - export interface ChangedElementsInspectorProps { manager: VersionCompareManager; onFilterChange?: (options: FilterOptions) => void; @@ -610,6 +609,20 @@ export class ChangedElementsListComponent extends Component { let ids: string[] = []; args.keys.instanceKeys.forEach((keys) => { ids = [...ids, ...keys]; }); + + // Callback for changed instance selections + const onSelectionCallback = this.props.manager.options.onInstancesSelected; + if (onSelectionCallback) { + const cache = this.props.manager.changedECInstanceCache; + const entries = this.props.manager.changedElementsManager.entryCache.getCached(ids); + const changedInstances = entries + .map((entry) => cache.getFromEntry(entry)) + .filter((instance) => instance !== undefined); + + // Don't await selection callback to avoid blocking the UI + void onSelectionCallback(changedInstances); + } + this.setState({ selectedIds: new Set(ids) }); }; @@ -850,6 +863,15 @@ export class ChangedElementsListComponent extends Component