(null);
const size = useResizeObserver(divRef);
const mergedRefs = useMemo(() => mergeRefs(divRef, ref), [divRef, ref]);
return {size && props.children(size)}
;
diff --git a/packages/changed-elements-react/src/api/VersionCompare.ts b/packages/changed-elements-react/src/api/VersionCompare.ts
index 5382bdee..05845df5 100644
--- a/packages/changed-elements-react/src/api/VersionCompare.ts
+++ b/packages/changed-elements-react/src/api/VersionCompare.ts
@@ -19,6 +19,8 @@ export interface VersionCompareFeatureTracking {
trackVersionSelectorV2Usage: () => void;
/** Track when the user opens the version compare selector dialog to start a comparison */
trackVersionSelectorUsage: () => void;
+ /** Track when the user opens the version compare selector dialog to start a comparison using V3 API */
+ trackVersionSelectorV3Usage?: () => void;
/** Tracks when the user does a property comparison and opens the side-by-side frontstage */
trackPropertyComparisonUsage: () => void;
/** Tracks when the user opens the change report dialog */
diff --git a/packages/changed-elements-react/src/clients/ComparisonJobClient.ts b/packages/changed-elements-react/src/clients/ComparisonJobClient.ts
index cc681861..8ecabf65 100644
--- a/packages/changed-elements-react/src/clients/ComparisonJobClient.ts
+++ b/packages/changed-elements-react/src/clients/ComparisonJobClient.ts
@@ -4,10 +4,11 @@
*--------------------------------------------------------------------------------------------*/
import type {
ChangedElementsPayload, IComparisonJobClient, ComparisonJob, GetComparisonJobParams, GetComparisonJobResultParams,
- PostComparisonJobParams,
+ PostComparisonJobParams, PostComparisonJobParamsWithIds,
DeleteComparisonJobParams
} from "./IComparisonJobClient.js";
import { callITwinApi, throwBadResponseCodeError } from "./iTwinApi.js";
+import { isChangedElementsPayload, isComparisonJob } from "./typeGuards.js";
export interface ComparisonJobClientParams {
baseUrl: string;
@@ -15,6 +16,7 @@ export interface ComparisonJobClientParams {
}
export class ComparisonJobClient implements IComparisonJobClient {
+ public readonly apiVersion = "v2" as const;
private static readonly _acceptHeader = "application/vnd.bentley.itwin-platform.v2+json";
private _baseUrl: string;
private _getAccessToken: () => Promise;
@@ -30,7 +32,7 @@ export class ComparisonJobClient implements IComparisonJobClient {
* @throws on a non 2XX response
*/
public async deleteComparisonJob(args: DeleteComparisonJobParams): Promise {
- return callITwinApi({
+ await callITwinApi({
url: `${this._baseUrl}/comparisonJob/${args.jobId}/iTwin/${args.iTwinId}/iModel/${args.iModelId}`,
method: "DELETE",
getAccessToken: this._getAccessToken,
@@ -39,7 +41,7 @@ export class ComparisonJobClient implements IComparisonJobClient {
Accept: ComparisonJobClient._acceptHeader,
...args.headers,
},
- }) as unknown as Promise;
+ });
}
/**
@@ -57,7 +59,8 @@ export class ComparisonJobClient implements IComparisonJobClient {
Accept: ComparisonJobClient._acceptHeader,
...args.headers,
},
- }) as unknown as Promise;
+ validate: isComparisonJob,
+ });
}
/**
@@ -79,7 +82,13 @@ export class ComparisonJobClient implements IComparisonJobClient {
if (!response.ok) {
await throwBadResponseCodeError(response, "Changed Elements request failed.");
}
- return response.json() as unknown as Promise;
+
+ const body: unknown = await response.json();
+ if (!isChangedElementsPayload(body)) {
+ throw new Error(`Changed Elements request to ${args.comparisonJob.comparison.href} returned an unexpected response shape.`);
+ }
+
+ return body;
}
/**
@@ -87,7 +96,12 @@ export class ComparisonJobClient implements IComparisonJobClient {
* @returns ComparisonJob
* @throws on a non 2XX response.
*/
+ public async postComparisonJob(args: PostComparisonJobParamsWithIds): Promise;
public async postComparisonJob(args: PostComparisonJobParams): Promise {
+ if (!args.startChangesetId || !args.endChangesetId) {
+ throw new Error("ComparisonJobClient requires startChangesetId and endChangesetId.");
+ }
+
return callITwinApi({
url: `${this._baseUrl}/comparisonJob`,
method: "POST",
@@ -103,6 +117,7 @@ export class ComparisonJobClient implements IComparisonJobClient {
startChangesetId: args.startChangesetId,
endChangesetId: args.endChangesetId,
},
- }) as unknown as Promise;
+ validate: isComparisonJob,
+ });
}
}
diff --git a/packages/changed-elements-react/src/clients/DiffJobClient.ts b/packages/changed-elements-react/src/clients/DiffJobClient.ts
new file mode 100644
index 00000000..243f824b
--- /dev/null
+++ b/packages/changed-elements-react/src/clients/DiffJobClient.ts
@@ -0,0 +1,670 @@
+/*---------------------------------------------------------------------------------------------
+* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
+* See LICENSE.md in the project root for license terms and full copyright notice.
+*--------------------------------------------------------------------------------------------*/
+import type {
+ ChangedElementsPayload, ComparisonJob, DeleteComparisonJobParams, DiffingStrategy, GetComparisonJobParams,
+ GetComparisonJobResultParams, IComparisonJobClient, PostComparisonJobParams, PostComparisonJobParamsWithIds,
+ PostComparisonJobParamsWithIndexes
+} from "./IComparisonJobClient.js";
+import type { IModelsClient } from "./iModelsClient.js";
+import { callITwinApi, throwBadResponseCodeError } from "./iTwinApi.js";
+import { isChangedElementsPayload, isRecord } from "./typeGuards.js";
+
+/**
+ * Structured error thrown when a requested diff job cannot be found.
+ * Satisfies the `{ code: "ComparisonNotFound" }` contract checked by callers.
+ */
+class ComparisonNotFoundError extends Error {
+ /** Discriminant used by callers to detect this error type without an `instanceof` check across module boundaries. */
+ public readonly code = "ComparisonNotFound" as const;
+
+ /** @param message Human-readable description of why the comparison could not be found. */
+ constructor(message: string) {
+ super(message);
+ this.name = "ComparisonNotFoundError";
+ }
+}
+
+/** Constructor parameters for {@link DiffJobClient}. */
+export interface DiffJobClientParams {
+ /** Base URL of the Changed Elements v3 (diff) API. */
+ baseUrl: string;
+
+ /** Callback that resolves the access token used to authenticate API requests. */
+ getAccessToken: () => Promise;
+
+ /** Client used to resolve changeset ids to changeset indexes. */
+ iModelsClient: IModelsClient;
+
+ /** Diffing strategy requested when creating new diff jobs. Defaults to `"VersionCompare"`. */
+ diffingStrategy?: DiffingStrategy;
+}
+
+/** Raw diff job shape as returned by the Changed Elements v3 (diff) API. */
+type DiffJob = {
+ /** Unique identifier of the diff job. */
+ jobId: string;
+
+ /** Current processing status of the diff job. */
+ status: "Queued" | "Started" | "Completed" | "Failed";
+
+ /** iTwin id the diff job belongs to. */
+ iTwinId: string;
+
+ /** iModel id the diff job belongs to. */
+ iModelId: string;
+
+ /** Changeset index the comparison starts from. */
+ startChangesetIndex: number;
+
+ /** Changeset index the comparison ends at. */
+ endChangesetIndex: number;
+
+ /** Diffing plan used to create the job, if reported by the API. */
+ diffingPlan?: {
+ /** Raw (non-normalized) strategy name. */
+ strategy?: string;
+ };
+
+ /** Raw (non-normalized) strategy name, reported directly on the job by some API versions. */
+ diffingStrategy?: string;
+
+ /** Pre-signed URL to the diff result. Present once the job status is `"Completed"`. */
+ href?: string;
+
+ /** Error message. Present when the job status is `"Failed"`. */
+ error?: string;
+
+ /** Number of diff agents that have finished processing. Present while the job is `"Started"`. */
+ completedAgents?: number;
+
+ /** Total number of diff agents assigned to the job. Present while the job is `"Started"`. */
+ totalAgents?: number;
+};
+
+/** Response body of a single diff job request (`GET`/`POST .../diff/{jobId}`). */
+type DiffJobResponse = {
+ /** The diff job returned by the API. */
+ job: DiffJob;
+};
+
+/** Response body of a diff job list request (`GET .../diff`). */
+type DiffJobListResponse = {
+ /** Diff jobs matching the list query. */
+ jobs: DiffJob[];
+};
+
+/**
+ * Type guard validating the shape of a raw {@link DiffJob} returned by the API.
+ *
+ * `status` is intentionally only checked to be a string here (not restricted to the known status
+ * literals): unrecognized status values are a valid API response and are rejected with a
+ * specific "unsupported diff job status" error by `DiffJobClient`'s private `_normalizeJob` instead.
+ * @param value Unknown value to validate.
+ */
+function isDiffJob(value: unknown): value is DiffJob {
+ return (
+ isRecord(value) &&
+ typeof value.jobId === "string" &&
+ typeof value.status === "string" &&
+ typeof value.iTwinId === "string" &&
+ typeof value.iModelId === "string" &&
+ typeof value.startChangesetIndex === "number" &&
+ typeof value.endChangesetIndex === "number"
+ );
+}
+
+/**
+ * Type guard validating the shape of a {@link DiffJobResponse}.
+ * @param value Unknown value to validate.
+ */
+function isDiffJobResponse(value: unknown): value is DiffJobResponse {
+ return isRecord(value) && isDiffJob(value.job);
+}
+
+/**
+ * Type guard validating the shape of a {@link DiffJobListResponse}.
+ * @param value Unknown value to validate.
+ */
+function isDiffJobListResponse(value: unknown): value is DiffJobListResponse {
+ return isRecord(value) && Array.isArray(value.jobs) && value.jobs.every(isDiffJob);
+}
+
+/** Matches a RFC 4122 v1-v5 UUID string, used to distinguish real diff job ids from composite (changeset pair) ids. */
+const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+/**
+ * `IComparisonJobClient` implementation backed by the Changed Elements v3 (diff) API.
+ *
+ * Unlike {@link ComparisonJobClient} (v2), this client accepts composite job ids of the form
+ * `${startChangesetId}-${endChangesetId}` in addition to real diff job UUIDs, resolving them to
+ * changeset indexes via the provided `IModelsClient` and caching the results.
+ */
+export class DiffJobClient implements IComparisonJobClient {
+ /** Indicates this client targets the Changed Elements v3 API. */
+ public readonly apiVersion = "v3" as const;
+ private static readonly _acceptHeader = "application/vnd.bentley.itwin-platform.v3+json";
+
+ /** Base URL of the Changed Elements v3 (diff) API. */
+ private readonly _baseUrl: string;
+
+ /** Resolves the access token used to authenticate API requests. */
+ private readonly _getAccessToken: () => Promise;
+
+ /** Client used to resolve changeset ids to changeset indexes. */
+ private readonly _iModelsClient: IModelsClient;
+
+ /** Default diffing strategy requested when creating new diff jobs. */
+ private readonly _diffingStrategy: DiffingStrategy;
+
+ /** Caches resolved changeset indexes, keyed by `${iModelId}:${changesetId}`. */
+ private readonly _changesetIndexCache = new Map();
+
+ /** Caches the diffing strategy used for composite job ids, keyed by `${startChangesetId}-${endChangesetId}`. */
+ private readonly _compositeJobStrategyCache = new Map();
+
+ /** @param args Constructor parameters, see {@link DiffJobClientParams}. */
+ constructor(args: DiffJobClientParams) {
+ this._baseUrl = args.baseUrl;
+ this._getAccessToken = args.getAccessToken;
+ this._iModelsClient = args.iModelsClient;
+ this._diffingStrategy = args.diffingStrategy ?? "VersionCompare";
+ }
+
+ /**
+ * Gets comparison job status.
+ *
+ * Accepts either a real diff job UUID, or a composite id of the form
+ * `${startChangesetId}-${endChangesetId}`, which is resolved to the matching diff job.
+ * @param args iTwin/iModel/job identification and request options.
+ * @returns ComparisonJob
+ * @throws a `ComparisonNotFoundError` (`{ code: "ComparisonNotFound" }`) if no matching job is found, or on any other non-2XX response.
+ */
+ public async getComparisonJob(args: GetComparisonJobParams): Promise {
+ try {
+ if (uuidPattern.test(args.jobId)) {
+ const response = await callITwinApi({
+ url: this._buildJobUrl(args.jobId, args.iTwinId, args.iModelId),
+ method: "GET",
+ getAccessToken: this._getAccessToken,
+ signal: args.signal,
+ headers: {
+ Accept: DiffJobClient._acceptHeader,
+ ...args.headers,
+ },
+ validate: isDiffJobResponse,
+ });
+ return this._normalizeJob(response.job);
+ }
+
+ const pair = await this._resolveCompositeJobId(args.iModelId, args.jobId);
+ if (!pair) {
+ throw new ComparisonNotFoundError(`Unsupported job identifier format: ${args.jobId}`);
+ }
+
+ const preferredStrategy = this._getPreferredStrategy(args.jobId);
+
+ let listResponse = await this._listDiffJobs({
+ iTwinId: args.iTwinId,
+ iModelId: args.iModelId,
+ startChangesetIndex: pair.startChangesetIndex,
+ endChangesetIndex: pair.endChangesetIndex,
+ diffingStrategy: preferredStrategy,
+ signal: args.signal,
+ headers: args.headers,
+ });
+
+ let firstJob = listResponse.jobs.find((job) => this._isMatchingStrategy(job, preferredStrategy));
+ if (!firstJob) {
+ // Fall back to an unfiltered list query to avoid missing jobs created with a non-default strategy.
+ listResponse = await this._listDiffJobs({
+ iTwinId: args.iTwinId,
+ iModelId: args.iModelId,
+ startChangesetIndex: pair.startChangesetIndex,
+ endChangesetIndex: pair.endChangesetIndex,
+ signal: args.signal,
+ headers: args.headers,
+ });
+ firstJob = this._pickBestStrategyMatch(listResponse.jobs, preferredStrategy);
+ }
+
+ if (!firstJob) {
+ throw new ComparisonNotFoundError("Requested comparison is not available.");
+ }
+
+ const detailedResponse = await callITwinApi({
+ url: this._buildJobUrl(firstJob.jobId, args.iTwinId, args.iModelId),
+ method: "GET",
+ getAccessToken: this._getAccessToken,
+ signal: args.signal,
+ headers: {
+ Accept: DiffJobClient._acceptHeader,
+ ...args.headers,
+ },
+ validate: isDiffJobResponse,
+ });
+
+ return this._normalizeJob(detailedResponse.job, pair.startChangesetId, pair.endChangesetId);
+ } catch (error: unknown) {
+ throw this._normalizeNotFoundError(error);
+ }
+ }
+
+ /**
+ * Deletes comparison job.
+ *
+ * Accepts either a real diff job UUID, or a composite id of the form
+ * `${startChangesetId}-${endChangesetId}`, which is resolved to the matching diff job before deletion.
+ * @param args iTwin/iModel/job identification and request options.
+ * @returns void
+ * @throws a `ComparisonNotFoundError` (`{ code: "ComparisonNotFound" }`) if no matching job is found, or on any other non-2XX response.
+ */
+ public async deleteComparisonJob(args: DeleteComparisonJobParams): Promise {
+ try {
+ let jobId = args.jobId;
+ if (!uuidPattern.test(jobId)) {
+ const pair = await this._resolveCompositeJobId(args.iModelId, jobId);
+ if (!pair) {
+ throw new ComparisonNotFoundError(`Unsupported job identifier format: ${jobId}`);
+ }
+ const preferredStrategy = this._getPreferredStrategy(jobId);
+ let listResponse = await this._listDiffJobs({
+ iTwinId: args.iTwinId,
+ iModelId: args.iModelId,
+ startChangesetIndex: pair.startChangesetIndex,
+ endChangesetIndex: pair.endChangesetIndex,
+ diffingStrategy: preferredStrategy,
+ signal: args.signal,
+ headers: args.headers,
+ });
+
+ let match = listResponse.jobs.find((job) => this._isMatchingStrategy(job, preferredStrategy));
+ if (!match) {
+ listResponse = await this._listDiffJobs({
+ iTwinId: args.iTwinId,
+ iModelId: args.iModelId,
+ startChangesetIndex: pair.startChangesetIndex,
+ endChangesetIndex: pair.endChangesetIndex,
+ signal: args.signal,
+ headers: args.headers,
+ });
+ match = this._pickBestStrategyMatch(listResponse.jobs, preferredStrategy);
+ }
+
+ if (!match) {
+ throw new ComparisonNotFoundError("Requested comparison is not available.");
+ }
+ jobId = match.jobId;
+ }
+
+ await callITwinApi({
+ url: this._buildJobUrl(jobId, args.iTwinId, args.iModelId),
+ method: "DELETE",
+ getAccessToken: this._getAccessToken,
+ signal: args.signal,
+ headers: {
+ Accept: DiffJobClient._acceptHeader,
+ ...args.headers,
+ },
+ });
+ } catch (error: unknown) {
+ throw this._normalizeNotFoundError(error);
+ }
+ }
+
+ /**
+ * Gets changed elements for given comparisonJob.
+ * @param args The comparison job whose result should be fetched, plus request options.
+ * @returns ChangedElements
+ * @throws on a non 2XX response, or if the response body does not match the expected shape.
+ */
+ public async getComparisonJobResult(args: GetComparisonJobResultParams): Promise {
+ // The href is a pre-signed URL (e.g. Azure Blob SAS URL); no Authorization header is needed or wanted.
+ const response = await fetch(
+ args.comparisonJob.comparison.href,
+ {
+ method: "GET",
+ signal: args.signal,
+ headers: {
+ Accept: DiffJobClient._acceptHeader,
+ },
+ },
+ );
+
+ if (!response.ok) {
+ await throwBadResponseCodeError(response, "Changed Elements request failed.");
+ }
+
+ const body: unknown = await response.json();
+ if (!isChangedElementsPayload(body)) {
+ throw new Error(`Changed Elements request to ${args.comparisonJob.comparison.href} returned an unexpected response shape.`);
+ }
+
+ return body;
+ }
+
+ /**
+ * Starts comparison job using changeset ids.
+ *
+ * Ids are resolved to changeset indexes (and cached) via the `IModelsClient`.
+ * @param args iTwin/iModel identification, changeset ids, diffing strategy, and request options.
+ * @returns ComparisonJob
+ * @throws on a non 2XX response.
+ */
+ public async postComparisonJob(args: PostComparisonJobParamsWithIds): Promise;
+ /**
+ * Starts comparison job using explicit changeset indexes.
+ * @param args iTwin/iModel identification, changeset indexes, diffing strategy, and request options.
+ * @returns ComparisonJob
+ * @throws on a non 2XX response.
+ */
+ public async postComparisonJob(args: PostComparisonJobParamsWithIndexes): Promise;
+ public async postComparisonJob(args: PostComparisonJobParams): Promise {
+ const startChangesetIndex = args.startChangesetIndex ??
+ await this._resolveChangesetIndex(args.iModelId, args.startChangesetId);
+ const endChangesetIndex = args.endChangesetIndex ??
+ await this._resolveChangesetIndex(args.iModelId, args.endChangesetId);
+
+ const requestedStrategy = args.diffingStrategy ?? this._diffingStrategy;
+
+ const response = await callITwinApi({
+ url: `${this._baseUrl}/diff`,
+ method: "POST",
+ getAccessToken: this._getAccessToken,
+ signal: args.signal,
+ headers: {
+ Accept: DiffJobClient._acceptHeader,
+ ...args.headers,
+ },
+ body: {
+ iTwinId: args.iTwinId,
+ iModelId: args.iModelId,
+ startChangesetIndex,
+ endChangesetIndex,
+ diffingPlan: {
+ strategy: requestedStrategy,
+ },
+ },
+ validate: isDiffJobResponse,
+ });
+
+ if (args.startChangesetId && args.endChangesetId) {
+ this._compositeJobStrategyCache.set(`${args.startChangesetId}-${args.endChangesetId}`, requestedStrategy);
+ }
+
+ return this._normalizeJob(response.job, args.startChangesetId, args.endChangesetId);
+ }
+
+ /**
+ * Resolves a changeset id to its numeric changeset index via the `IModelsClient`, caching the result.
+ * @param iModelId iModel the changeset belongs to.
+ * @param changesetId Changeset id to resolve, or `undefined`.
+ * @throws if `changesetId` is undefined, or if the changeset cannot be found (as a `ComparisonNotFoundError`).
+ */
+ private async _resolveChangesetIndex(iModelId: string, changesetId: string | undefined): Promise {
+ if (!changesetId) {
+ throw new Error("Missing required changeset identifier.");
+ }
+
+ const cacheKey = `${iModelId}:${changesetId}`;
+ const cached = this._changesetIndexCache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const changeset = await this._iModelsClient.getChangeset({
+ iModelId,
+ changesetId,
+ });
+ if (!changeset) {
+ throw new ComparisonNotFoundError(`Could not resolve changeset index for '${changesetId}'.`);
+ }
+
+ this._changesetIndexCache.set(cacheKey, changeset.index);
+ return changeset.index;
+ }
+
+ /**
+ * Converts a raw {@link DiffJob} (v3 API shape) into the public, discriminated-union `ComparisonJob` shape.
+ * @param job Raw diff job to normalize.
+ * @param startChangesetId Changeset id the comparison starts from, if known (e.g. from a composite job id).
+ * @param endChangesetId Changeset id the comparison ends at, if known (e.g. from a composite job id).
+ * @throws if `job.status` is `"Completed"` but missing its result `href`, or if `job.status` is not a recognized value.
+ */
+ private _normalizeJob(job: DiffJob, startChangesetId?: string, endChangesetId?: string): ComparisonJob {
+ const common = {
+ jobId: job.jobId,
+ iTwinId: job.iTwinId,
+ iModelId: job.iModelId,
+ startChangesetId,
+ endChangesetId,
+ startChangesetIndex: job.startChangesetIndex,
+ endChangesetIndex: job.endChangesetIndex,
+ diffingPlan: {
+ strategy: this._normalizeStrategy(job.diffingPlan?.strategy ?? job.diffingStrategy) ?? this._diffingStrategy,
+ },
+ };
+
+ switch (job.status) {
+ case "Completed":
+ if (!job.href) {
+ throw new Error(`Completed diff job '${job.jobId}' is missing required href.`);
+ }
+ return {
+ comparisonJob: {
+ ...common,
+ status: "Completed",
+ comparison: {
+ href: job.href,
+ },
+ },
+ };
+ case "Started":
+ return {
+ comparisonJob: {
+ ...common,
+ status: "Started",
+ completedAgents: job.completedAgents,
+ totalAgents: job.totalAgents,
+ currentProgress: job.completedAgents ?? 0,
+ maxProgress: job.totalAgents ?? 0,
+ },
+ };
+ case "Failed":
+ return {
+ comparisonJob: {
+ ...common,
+ status: "Failed",
+ errorDetails: job.error ?? "Diff job failed.",
+ },
+ };
+ case "Queued":
+ return {
+ comparisonJob: {
+ ...common,
+ status: "Queued",
+ },
+ };
+ default:
+ throw new Error(`Received unsupported diff job status '${String(job.status)}'.`);
+ }
+ }
+
+ /**
+ * Attempts to split a composite job id (`${startChangesetId}-${endChangesetId}`) into its two
+ * changeset ids and resolve both to changeset indexes, trying every dash as a possible split
+ * point (starting from the one closest to the midpoint) to support opaque changeset ids that
+ * may themselves contain dashes.
+ * @param iModelId iModel the changesets belong to.
+ * @param jobId Composite job id of the form `${startChangesetId}-${endChangesetId}`.
+ * @returns The resolved changeset id/index pair, or `undefined` if `jobId` has no dashes or no split resolves successfully.
+ */
+ private async _resolveCompositeJobId(
+ iModelId: string,
+ jobId: string,
+ ): Promise<{
+ startChangesetId: string;
+ endChangesetId: string;
+ startChangesetIndex: number;
+ endChangesetIndex: number;
+ } | undefined> {
+ const separatorIndexes: number[] = [];
+ for (let i = 0; i < jobId.length; i++) {
+ if (jobId[i] === "-") {
+ separatorIndexes.push(i);
+ }
+ }
+
+ if (separatorIndexes.length === 0) {
+ return undefined;
+ }
+
+ // Sort candidates by distance from the midpoint so that equal-length ID pairs
+ // (e.g. two UUIDs or two SHA-1 hashes) are resolved in a single attempt rather
+ // than scanning from the leftmost dash inward.
+ const midpoint = (jobId.length - 1) / 2;
+ const sorted = separatorIndexes.slice().sort(
+ (a, b) => Math.abs(a - midpoint) - Math.abs(b - midpoint),
+ );
+
+ // We try every possible split to support opaque changeset ids.
+ for (const splitIndex of sorted) {
+ const startChangesetId = jobId.slice(0, splitIndex);
+ const endChangesetId = jobId.slice(splitIndex + 1);
+ if (!startChangesetId || !endChangesetId) {
+ continue;
+ }
+
+ try {
+ const startChangesetIndex = await this._resolveChangesetIndex(iModelId, startChangesetId);
+ const endChangesetIndex = await this._resolveChangesetIndex(iModelId, endChangesetId);
+ return {
+ startChangesetId,
+ endChangesetId,
+ startChangesetIndex,
+ endChangesetIndex,
+ };
+ } catch (error) {
+ const code = error && typeof error === "object" && "code" in error
+ ? (error as Record).code
+ : undefined;
+ if (code === "ComparisonNotFound") {
+ // Try next split candidate when one side of the split does not resolve.
+ continue;
+ }
+
+ throw error;
+ }
+ }
+
+ return undefined;
+ }
+
+ /**
+ * Lists diff jobs matching the given iTwin/iModel/changeset-range (and optionally strategy) query.
+ * @param args iTwin/iModel identification, changeset index range, optional diffing strategy filter, and request options.
+ * @throws on a non 2XX response, or if the response body does not match the expected shape.
+ */
+ private async _listDiffJobs(args: {
+ iTwinId: string;
+ iModelId: string;
+ startChangesetIndex: number;
+ endChangesetIndex: number;
+ diffingStrategy?: DiffingStrategy;
+ signal?: AbortSignal;
+ headers?: Record;
+ }): Promise {
+ const url = new URL(`${this._baseUrl}/diff`);
+ url.searchParams.set("iTwinId", args.iTwinId);
+ url.searchParams.set("iModelId", args.iModelId);
+ url.searchParams.set("startChangesetIndex", String(args.startChangesetIndex));
+ url.searchParams.set("endChangesetIndex", String(args.endChangesetIndex));
+ if (args.diffingStrategy) {
+ url.searchParams.set("diffingStrategy", args.diffingStrategy);
+ }
+
+ return callITwinApi({
+ url: url.toString(),
+ method: "GET",
+ getAccessToken: this._getAccessToken,
+ signal: args.signal,
+ headers: {
+ Accept: DiffJobClient._acceptHeader,
+ ...args.headers,
+ },
+ validate: isDiffJobListResponse,
+ });
+ }
+
+ /**
+ * Builds the URL for a single diff job resource (`.../diff/{jobId}?iTwinId=...&iModelId=...`).
+ * @param jobId Diff job id.
+ * @param iTwinId iTwin id the job belongs to.
+ * @param iModelId iModel id the job belongs to.
+ */
+ private _buildJobUrl(jobId: string, iTwinId: string, iModelId: string): string {
+ const url = new URL(`${this._baseUrl}/diff/${encodeURIComponent(jobId)}`);
+ url.searchParams.set("iTwinId", iTwinId);
+ url.searchParams.set("iModelId", iModelId);
+ return url.toString();
+ }
+
+ /**
+ * Returns the diffing strategy previously used for a composite job id, falling back to the client's default.
+ * @param jobId Composite job id previously passed to {@link DiffJobClient.postComparisonJob}.
+ */
+ private _getPreferredStrategy(jobId: string): DiffingStrategy {
+ return this._compositeJobStrategyCache.get(jobId) ?? this._diffingStrategy;
+ }
+
+ /**
+ * Picks the best matching job from an unfiltered job list: prefers a job matching
+ * `preferredStrategy`, falling back to one matching the client's default strategy.
+ * @param jobs Candidate jobs to search.
+ * @param preferredStrategy Diffing strategy to prefer.
+ */
+ private _pickBestStrategyMatch(jobs: DiffJob[], preferredStrategy: DiffingStrategy): DiffJob | undefined {
+ return jobs.find((job) => this._isMatchingStrategy(job, preferredStrategy))
+ ?? jobs.find((job) => this._isMatchingStrategy(job, this._diffingStrategy));
+ }
+
+ /**
+ * Checks whether a job's (normalized) diffing strategy matches `strategy`.
+ * @param job Job to check.
+ * @param strategy Diffing strategy to compare against.
+ */
+ private _isMatchingStrategy(job: DiffJob, strategy: DiffingStrategy): boolean {
+ return this._normalizeStrategy(job.diffingPlan?.strategy ?? job.diffingStrategy) === strategy;
+ }
+
+ /**
+ * Normalizes a raw, case-insensitive strategy string from the API into a `DiffingStrategy`, or `undefined` if unrecognized.
+ * @param strategy Raw strategy string reported by the API, or `undefined`.
+ */
+ private _normalizeStrategy(strategy: string | undefined): DiffingStrategy | undefined {
+ switch (strategy?.toLowerCase()) {
+ case "basic": return "Basic";
+ case "full": return "Full";
+ case "versioncompare": return "VersionCompare";
+ default: return undefined;
+ }
+ }
+
+ /**
+ * Converts a `{ code: "DiffJobNotFound" }` error from the API into a `ComparisonNotFoundError`; other errors are passed through unchanged.
+ * @param error Error caught from an API call.
+ */
+ private _normalizeNotFoundError(error: unknown): unknown {
+ if (error && typeof error === "object" && "code" in error) {
+ const code = (error as Record).code;
+ if (code === "DiffJobNotFound") {
+ const rawMessage = (error as Record).message;
+ const message = typeof rawMessage === "string" ? rawMessage : "Requested comparison is not available.";
+ return new ComparisonNotFoundError(message);
+ }
+ }
+
+ return error;
+ }
+}
diff --git a/packages/changed-elements-react/src/clients/IComparisonJobClient.ts b/packages/changed-elements-react/src/clients/IComparisonJobClient.ts
index 0293f114..0a13c6a3 100644
--- a/packages/changed-elements-react/src/clients/IComparisonJobClient.ts
+++ b/packages/changed-elements-react/src/clients/IComparisonJobClient.ts
@@ -4,7 +4,13 @@
*--------------------------------------------------------------------------------------------*/
import { ChangedElements } from "@itwin/core-common";
+export type ChangedElementsApiVersion = "v2" | "v3";
+export type DiffingStrategy = "Basic" | "VersionCompare" | "Full";
+
export interface IComparisonJobClient {
+ /** Indicates which Changed Elements API version this client targets. */
+ readonly apiVersion: ChangedElementsApiVersion;
+
/** Gets comparison job status. Throws on encountering an error or receiving non-success response code. */
getComparisonJob(args: GetComparisonJobParams): Promise;
@@ -15,7 +21,7 @@ export interface IComparisonJobClient {
getComparisonJobResult(args: GetComparisonJobResultParams): Promise;
/** Starts comparison job. Throws on encountering an error or receiving non-success response code. */
- postComparisonJob(args: PostComparisonJobParams): Promise;
+ postComparisonJob(args: PostComparisonJobParamsWithIds): Promise;
}
@@ -31,14 +37,22 @@ export interface DeleteComparisonJobParams extends GetComparisonJobParams { }
export type ComparisonJob = ComparisonJobCompleted | ComparisonJobStarted | ComparisonJobQueued | ComparisonJobFailed;
+interface ComparisonJobCommon {
+ jobId: string;
+ iTwinId: string;
+ iModelId: string;
+ startChangesetId?: string;
+ endChangesetId?: string;
+ startChangesetIndex?: number;
+ endChangesetIndex?: number;
+ diffingPlan?: {
+ strategy: DiffingStrategy;
+ };
+}
+
export interface ComparisonJobCompleted {
- comparisonJob: {
+ comparisonJob: ComparisonJobCommon & {
status: "Completed";
- jobId: string;
- iTwinId: string;
- iModelId: string;
- startChangesetId: string;
- endChangesetId: string;
comparison: {
href: string;
};
@@ -46,37 +60,24 @@ export interface ComparisonJobCompleted {
}
export interface ComparisonJobStarted {
- comparisonJob: {
+ comparisonJob: ComparisonJobCommon & {
status: "Started";
- jobId: string;
- iTwinId: string;
- iModelId: string;
- startChangesetId: string;
- endChangesetId: string;
currentProgress: number;
maxProgress: number;
+ completedAgents?: number;
+ totalAgents?: number;
};
}
export interface ComparisonJobQueued {
- comparisonJob: {
+ comparisonJob: ComparisonJobCommon & {
status: "Queued";
- jobId: string;
- iTwinId: string;
- iModelId: string;
- startChangesetId: string;
- endChangesetId: string;
};
}
export interface ComparisonJobFailed {
- comparisonJob: {
+ comparisonJob: ComparisonJobCommon & {
status: "Failed";
- jobId: string;
- iTwinId: string;
- iModelId: string;
- startChangesetId: string;
- endChangesetId: string;
errorDetails: string;
};
}
@@ -89,13 +90,28 @@ export interface ChangedElementsPayload {
changedElements: ChangedElements;
}
-export interface PostComparisonJobParams extends BodilessRequest {
+interface PostComparisonJobParamsBase extends BodilessRequest {
iTwinId: string;
iModelId: string;
+ diffingStrategy?: DiffingStrategy;
+}
+
+export interface PostComparisonJobParamsWithIds extends PostComparisonJobParamsBase {
startChangesetId: string;
endChangesetId: string;
+ startChangesetIndex?: never;
+ endChangesetIndex?: never;
}
+export interface PostComparisonJobParamsWithIndexes extends PostComparisonJobParamsBase {
+ startChangesetIndex: number;
+ endChangesetIndex: number;
+ startChangesetId?: string;
+ endChangesetId?: string;
+}
+
+export type PostComparisonJobParams = PostComparisonJobParamsWithIds | PostComparisonJobParamsWithIndexes;
+
export interface CommonRequestParams {
signal?: AbortSignal | undefined;
headers?: Record | undefined;
diff --git a/packages/changed-elements-react/src/clients/iTwinApi.ts b/packages/changed-elements-react/src/clients/iTwinApi.ts
index 511181c6..5e35a7a9 100644
--- a/packages/changed-elements-react/src/clients/iTwinApi.ts
+++ b/packages/changed-elements-react/src/clients/iTwinApi.ts
@@ -13,7 +13,21 @@ export interface CallITwinApiParams {
body?: Record | undefined;
}
-export async function callITwinApi(args: CallITwinApiParams): Promise | undefined> {
+/**
+ * Calls the iTwin API and returns the raw, unvalidated JSON body.
+ * @throws on a non 2XX response.
+ */
+export async function callITwinApi(args: CallITwinApiParams): Promise | undefined>;
+/**
+ * Calls the iTwin API and validates the JSON body against the provided type guard.
+ * @throws on a non 2XX response, or if the response body does not satisfy `validate`.
+ */
+export async function callITwinApi(
+ args: CallITwinApiParams & { validate: (value: unknown) => value is T; },
+): Promise;
+export async function callITwinApi(
+ args: CallITwinApiParams & { validate?: (value: unknown) => value is T; },
+): Promise | undefined> {
const response = await fetch(
args.url,
{
@@ -30,10 +44,16 @@ export async function callITwinApi(args: CallITwinApiParams): Promise {
- const changeset = await callITwinApi({
+ const response = await callITwinApi({
url: `${this.baseUrl}/${args.iModelId}/changesets/${args.changesetId}`,
getAccessToken: this.getAccessToken,
signal: args.signal,
headers: { Accept: acceptMimeType },
});
- return changeset?.changeset as Changeset | undefined;
+ if (response === undefined) {
+ return undefined;
+ }
+ if (!isChangeset(response.changeset)) {
+ throw new Error(`Changeset request for ${args.changesetId} returned an unexpected response shape.`);
+ }
+ return response.changeset;
}
public async getChangesets(args: GetChangesetsParams): Promise {
@@ -42,12 +50,15 @@ export class ITwinIModelsClient implements IModelsClient {
headers: { Accept: acceptMimeType },
});
- const pages: Array = [];
+ const pages: Changeset[][] = [];
for await (const page of iterator) {
- pages.push((page as { changesets: unknown[]; }).changesets);
+ if (!isChangesetsPage(page)) {
+ throw new Error(`Changesets request for iModel ${args.iModelId} returned an unexpected response shape.`);
+ }
+ pages.push(page.changesets);
}
- return pages.flat() as Changeset[];
+ return pages.flat();
}
public async getNamedVersions(args: GetNamedVersionsParams): Promise {
@@ -64,8 +75,8 @@ export class ITwinIModelsClient implements IModelsClient {
signal: args.signal,
headers: { Accept: acceptMimeType, Prefer: "return=representation" },
});
- if (!response || !Array.isArray(response?.namedVersions)) return [];
- return response.namedVersions as NamedVersion[];
+ if (!response || !isNamedVersionsPage(response)) return [];
+ return response.namedVersions;
}
if (args.orderby) urlParams = `${urlParams}$orderBy=${args.orderby} `;
@@ -78,17 +89,20 @@ export class ITwinIModelsClient implements IModelsClient {
headers: { Accept: acceptMimeType, Prefer: "return=representation" },
});
- const pages: Array = [];
+ const pages: NamedVersionResponseItem[][] = [];
for await (const page of iterator) {
- pages.push((page as { namedVersions: unknown[]; }).namedVersions);
+ if (!isNamedVersionsPage(page)) {
+ throw new Error(`Named versions request for iModel ${args.iModelId} returned an unexpected response shape.`);
+ }
+ pages.push(page.namedVersions);
}
- let result = pages.flat();
+ let result: NamedVersionResponseItem[] = pages.flat();
if (!this.showHiddenNamedVersions) {
- result = (result as Array<{ state: "visible" | "hidden"; }>).filter(({ state }) => state === "visible");
+ result = result.filter(({ state }) => state === "visible");
}
- return result as NamedVersion[];
+ return result;
}
}
diff --git a/packages/changed-elements-react/src/clients/typeGuards.ts b/packages/changed-elements-react/src/clients/typeGuards.ts
new file mode 100644
index 00000000..afcc26ad
--- /dev/null
+++ b/packages/changed-elements-react/src/clients/typeGuards.ts
@@ -0,0 +1,94 @@
+/*---------------------------------------------------------------------------------------------
+* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
+* See LICENSE.md in the project root for license terms and full copyright notice.
+*--------------------------------------------------------------------------------------------*/
+import type { ChangedElementsPayload, ComparisonJob } from "./IComparisonJobClient.js";
+import type { Changeset, NamedVersion } from "./iModelsClient.js";
+
+/** Narrows `value` to a plain object record, allowing safe property access without casting. */
+export function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+/** Validates the shape of a `comparisonJob` API response (Changed Elements v2). */
+export function isComparisonJob(value: unknown): value is ComparisonJob {
+ if (!isRecord(value) || !isRecord(value.comparisonJob)) {
+ return false;
+ }
+
+ const job = value.comparisonJob;
+ if (
+ typeof job.jobId !== "string" ||
+ typeof job.iTwinId !== "string" ||
+ typeof job.iModelId !== "string"
+ ) {
+ return false;
+ }
+
+ switch (job.status) {
+ case "Completed":
+ return isRecord(job.comparison) && typeof job.comparison.href === "string";
+ case "Started":
+ return typeof job.currentProgress === "number" && typeof job.maxProgress === "number";
+ case "Queued":
+ return true;
+ case "Failed":
+ return typeof job.errorDetails === "string";
+ default:
+ return false;
+ }
+}
+
+/**
+ * Validates the shape of a Changed Elements payload. Only the top-level `changedElements`
+ * property is checked -- the nested `ChangedElements` structure (from `@itwin/core-common`)
+ * is not exhaustively validated.
+ */
+export function isChangedElementsPayload(value: unknown): value is ChangedElementsPayload {
+ return isRecord(value) && isRecord(value.changedElements);
+}
+
+/** Validates the shape of a single Changeset returned by the iTwin iModels API. */
+export function isChangeset(value: unknown): value is Changeset {
+ return (
+ isRecord(value) &&
+ typeof value.id === "string" &&
+ typeof value.displayName === "string" &&
+ typeof value.description === "string" &&
+ typeof value.index === "number" &&
+ typeof value.parentId === "string" &&
+ typeof value.creatorId === "string" &&
+ typeof value.pushDateTime === "string"
+ );
+}
+
+/** Validates a paged `{ changesets: Changeset[] }` response. */
+export function isChangesetsPage(value: unknown): value is { changesets: Changeset[]; } {
+ return isRecord(value) && Array.isArray(value.changesets) && value.changesets.every(isChangeset);
+}
+
+/**
+ * Raw named version item as returned by the API, including the `state` field used to filter
+ * hidden Named Versions. `state` is not part of the public `NamedVersion` type, but the extra
+ * property is structurally compatible with `NamedVersion[]` when returned.
+ */
+export type NamedVersionResponseItem = NamedVersion & { state: "visible" | "hidden"; };
+
+/** Validates the shape of a single Named Version returned by the iTwin iModels API. */
+export function isNamedVersionResponseItem(value: unknown): value is NamedVersionResponseItem {
+ return (
+ isRecord(value) &&
+ typeof value.id === "string" &&
+ typeof value.displayName === "string" &&
+ (typeof value.changesetId === "string" || value.changesetId === null) &&
+ typeof value.changesetIndex === "number" &&
+ (typeof value.description === "string" || value.description === null) &&
+ typeof value.createdDateTime === "string" &&
+ (value.state === "visible" || value.state === "hidden")
+ );
+}
+
+/** Validates a paged `{ namedVersions: NamedVersionResponseItem[] }` response. */
+export function isNamedVersionsPage(value: unknown): value is { namedVersions: NamedVersionResponseItem[]; } {
+ return isRecord(value) && Array.isArray(value.namedVersions) && value.namedVersions.every(isNamedVersionResponseItem);
+}
diff --git a/packages/changed-elements-react/src/index.ts b/packages/changed-elements-react/src/index.ts
index aac74c77..d83f6825 100644
--- a/packages/changed-elements-react/src/index.ts
+++ b/packages/changed-elements-react/src/index.ts
@@ -17,9 +17,13 @@ export type { MainVisualizationOptions, VisualizationHandler } from "./api/Visua
export {
ComparisonJobClient, type ComparisonJobClientParams,
} from "./clients/ComparisonJobClient.js";
+export {
+ DiffJobClient, type DiffJobClientParams,
+} from "./clients/DiffJobClient.js";
export type {
+ ChangedElementsApiVersion,
ComparisonJob, ComparisonJobCompleted, ComparisonJobFailed, ComparisonJobQueued,
- ComparisonJobStarted,
+ ComparisonJobStarted, DiffingStrategy, IComparisonJobClient,
} from "./clients/IComparisonJobClient.js";
export type {
Changeset, GetChangesetsParams, GetNamedVersionsParams, IModelsClient, NamedVersion,
diff --git a/packages/changed-elements-react/src/tests/DiffJobClient.test.ts b/packages/changed-elements-react/src/tests/DiffJobClient.test.ts
new file mode 100644
index 00000000..3786cc2e
--- /dev/null
+++ b/packages/changed-elements-react/src/tests/DiffJobClient.test.ts
@@ -0,0 +1,627 @@
+/*---------------------------------------------------------------------------------------------
+* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
+* See LICENSE.md in the project root for license terms and full copyright notice.
+*--------------------------------------------------------------------------------------------*/
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { DiffJobClient } from "../clients/DiffJobClient.js";
+import type { IModelsClient } from "../clients/iModelsClient.js";
+
+const createChangeset = (id: string, index: number) => ({
+ id,
+ index,
+ displayName: `changeset-${index}`,
+ description: "",
+ parentId: "",
+ creatorId: "",
+ pushDateTime: "",
+});
+
+describe("DiffJobClient", () => {
+ const iTwinId = "a1b2c3d4-0000-0000-0000-000000000000";
+ const iModelId = "b1b2c3d4-0000-0000-0000-000000000000";
+ const startChangesetId = "11111111-1111-4111-8111-111111111111";
+ const endChangesetId = "22222222-2222-4222-8222-222222222222";
+
+ const iModelsClient = {
+ getChangesets: vi.fn(),
+ getNamedVersions: vi.fn(),
+ getChangeset: vi.fn(),
+ } as unknown as IModelsClient;
+
+ const fetchMock = vi.fn();
+
+ beforeEach(() => {
+ fetchMock.mockReset();
+ vi.mocked(iModelsClient.getChangeset).mockReset();
+ vi.mocked(iModelsClient.getChangesets).mockReset();
+ vi.mocked(iModelsClient.getNamedVersions).mockReset();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("posts v3 diff jobs using VersionCompare strategy and changeset indexes", async () => {
+ vi.mocked(iModelsClient.getChangeset)
+ .mockResolvedValueOnce(createChangeset(startChangesetId, 4))
+ .mockResolvedValueOnce(createChangeset(endChangesetId, 8));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ job: {
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "VersionCompare" },
+ },
+ }), { status: 202 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ diffingStrategy: "VersionCompare",
+ });
+
+ const result = await client.postComparisonJob({
+ iTwinId,
+ iModelId,
+ startChangesetId,
+ endChangesetId,
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const body = JSON.parse(request.body as string) as {
+ startChangesetIndex: number;
+ endChangesetIndex: number;
+ diffingPlan: { strategy: string; };
+ };
+ expect(body.startChangesetIndex).toBe(4);
+ expect(body.endChangesetIndex).toBe(8);
+ expect(body.diffingPlan.strategy).toBe("VersionCompare");
+ expect(result.comparisonJob.status).toBe("Queued");
+ expect(result.comparisonJob.startChangesetId).toBe(startChangesetId);
+ expect(result.comparisonJob.endChangesetId).toBe(endChangesetId);
+ });
+
+ it("reuses per-request strategy when resolving composite ids after posting", async () => {
+ vi.mocked(iModelsClient.getChangeset)
+ .mockResolvedValueOnce(createChangeset(startChangesetId, 4))
+ .mockResolvedValueOnce(createChangeset(endChangesetId, 8));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ job: {
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "Full" },
+ },
+ }), { status: 202 }));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ jobs: [{
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "Full" },
+ }],
+ }), { status: 200 }));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ job: {
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "Full" },
+ },
+ }), { status: 200 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ diffingStrategy: "VersionCompare",
+ });
+
+ await client.postComparisonJob({
+ iTwinId,
+ iModelId,
+ startChangesetId,
+ endChangesetId,
+ diffingStrategy: "Full",
+ headers: { "Content-Type": "application/json" },
+ });
+
+ await client.getComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: `${startChangesetId}-${endChangesetId}`,
+ });
+
+ const [listUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
+ expect(listUrl).toContain("diffingStrategy=Full");
+ });
+
+ it("resolves composite ids through list and hydrates completed href from detailed endpoint", async () => {
+ vi.mocked(iModelsClient.getChangeset)
+ .mockResolvedValueOnce(createChangeset(startChangesetId, 4))
+ .mockResolvedValueOnce(createChangeset(endChangesetId, 8));
+
+ const jobId = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ jobs: [{
+ jobId,
+ status: "Completed",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "VersionCompare" },
+ }],
+ }), { status: 200 }));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ job: {
+ jobId,
+ status: "Completed",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "VersionCompare" },
+ href: "https://example.test/results",
+ },
+ }), { status: 200 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ const result = await client.getComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: `${startChangesetId}-${endChangesetId}`,
+ });
+
+ expect(result.comparisonJob.status).toBe("Completed");
+ if (result.comparisonJob.status !== "Completed") {
+ throw new Error("Expected completed comparison job.");
+ }
+ expect(result.comparisonJob.comparison.href).toBe("https://example.test/results");
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("matches lowercase diffingStrategy values from list responses", async () => {
+ vi.mocked(iModelsClient.getChangeset)
+ .mockResolvedValueOnce(createChangeset(startChangesetId, 4))
+ .mockResolvedValueOnce(createChangeset(endChangesetId, 8));
+
+ const jobId = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ jobs: [{
+ jobId,
+ status: "Completed",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingStrategy: "versioncompare",
+ }],
+ }), { status: 200 }));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ job: {
+ jobId,
+ status: "Completed",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingStrategy: "versioncompare",
+ href: "https://example.test/results",
+ },
+ }), { status: 200 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ const result = await client.getComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: `${startChangesetId}-${endChangesetId}`,
+ });
+
+ expect(result.comparisonJob.status).toBe("Completed");
+ if (result.comparisonJob.status !== "Completed") {
+ throw new Error("Expected completed comparison job.");
+ }
+ expect(result.comparisonJob.comparison.href).toBe("https://example.test/results");
+ expect(result.comparisonJob.diffingPlan?.strategy).toBe("VersionCompare");
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("resolves UUID composite job IDs by trying the midpoint split first", async () => {
+ // startChangesetId and endChangesetId are both UUIDs (36 chars each).
+ // Their composite "uuid1-uuid2" has 9 dashes; the correct separator is at
+ // index 36 — the midpoint. After sorting by distance from midpoint, this
+ // candidate is tried first, so getChangeset is called exactly twice.
+ vi.mocked(iModelsClient.getChangeset)
+ .mockImplementation(async ({ changesetId }) => {
+ if (changesetId === startChangesetId)
+ return createChangeset(startChangesetId, 4);
+ if (changesetId === endChangesetId)
+ return createChangeset(endChangesetId, 8);
+ return undefined;
+ });
+
+ const jobId = `${startChangesetId}-${endChangesetId}`;
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ jobs: [{
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "VersionCompare" },
+ }],
+ }), { status: 200 }));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ job: {
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "VersionCompare" },
+ },
+ }), { status: 200 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ const result = await client.getComparisonJob({ iTwinId, iModelId, jobId });
+
+ expect(result.comparisonJob.status).toBe("Queued");
+ // Midpoint-first sorting means the correct split is tried on the first attempt.
+ expect(vi.mocked(iModelsClient.getChangeset)).toHaveBeenCalledTimes(2);
+ expect(vi.mocked(iModelsClient.getChangeset)).toHaveBeenCalledWith({ iModelId, changesetId: startChangesetId });
+ expect(vi.mocked(iModelsClient.getChangeset)).toHaveBeenCalledWith({ iModelId, changesetId: endChangesetId });
+ });
+
+ it("maps DiffJobNotFound to ComparisonNotFound", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ error: {
+ code: "DiffJobNotFound",
+ message: "Requested DiffJob is not available.",
+ },
+ }), { status: 404 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ await expect(client.getComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ })).rejects.toMatchObject({ code: "ComparisonNotFound" });
+ });
+
+ it("rejects unsupported delete job identifiers instead of silently succeeding", async () => {
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ await expect(client.deleteComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: "not-a-composite-job-id",
+ })).rejects.toMatchObject({ code: "ComparisonNotFound" });
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("maps DiffJobNotFound during delete to ComparisonNotFound", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ error: {
+ code: "DiffJobNotFound",
+ message: "Requested DiffJob is not available.",
+ },
+ }), { status: 404 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ await expect(client.deleteComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ })).rejects.toMatchObject({ code: "ComparisonNotFound" });
+ });
+
+ it("maps DiffJobNotFound raised while resolving a composite id for delete to ComparisonNotFound", async () => {
+ vi.mocked(iModelsClient.getChangeset)
+ .mockResolvedValueOnce(createChangeset(startChangesetId, 4))
+ .mockResolvedValueOnce(createChangeset(endChangesetId, 8));
+
+ // The list query used to resolve the composite id into a UUID fails with DiffJobNotFound.
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ error: {
+ code: "DiffJobNotFound",
+ message: "Requested DiffJob is not available.",
+ },
+ }), { status: 404 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ await expect(client.deleteComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: `${startChangesetId}-${endChangesetId}`,
+ })).rejects.toMatchObject({ code: "ComparisonNotFound" });
+ });
+
+ it("resolves hash-like composite ids and issues filtered /diff query", async () => {
+ const hashStart = "16063aa71dfbcee75d32a7c5a31ca40e9bb2b094";
+ const hashEnd = "8968f5c4449d26c0dababf37aed17dcc49d7059f";
+
+ vi.mocked(iModelsClient.getChangeset)
+ .mockImplementation(async ({ changesetId }) => {
+ if (changesetId === hashStart)
+ return createChangeset(hashStart, 3);
+ if (changesetId === hashEnd)
+ return createChangeset(hashEnd, 7);
+ return undefined;
+ });
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ jobs: [{
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 3,
+ endChangesetIndex: 7,
+ diffingPlan: { strategy: "VersionCompare" },
+ }],
+ }), { status: 200 }));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ job: {
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 3,
+ endChangesetIndex: 7,
+ diffingPlan: { strategy: "VersionCompare" },
+ },
+ }), { status: 200 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ const result = await client.getComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: `${hashStart}-${hashEnd}`,
+ });
+
+ expect(result.comparisonJob.status).toBe("Queued");
+ expect(fetchMock).toHaveBeenCalled();
+ const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(firstUrl).toContain("/diff?");
+ expect(firstUrl).toContain("startChangesetIndex=3");
+ expect(firstUrl).toContain("endChangesetIndex=7");
+ expect(firstUrl).toContain("diffingStrategy=VersionCompare");
+ });
+
+ it("forwards signal and headers when listing jobs for composite ids", async () => {
+ vi.mocked(iModelsClient.getChangeset)
+ .mockResolvedValueOnce(createChangeset(startChangesetId, 4))
+ .mockResolvedValueOnce(createChangeset(endChangesetId, 8));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ jobs: [{
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "VersionCompare" },
+ }],
+ }), { status: 200 }));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ job: {
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "VersionCompare" },
+ },
+ }), { status: 200 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ const signal = new AbortController().signal;
+ await client.getComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: `${startChangesetId}-${endChangesetId}`,
+ signal,
+ headers: {
+ "X-Test-Header": "test-value",
+ },
+ });
+
+ const [, firstRequest] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(firstRequest.signal).toBe(signal);
+ expect(firstRequest.headers).toMatchObject({
+ "X-Test-Header": "test-value",
+ });
+ });
+
+ it("forwards signal when downloading comparison job results", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ changedElements: {
+ elements: [],
+ },
+ }), { status: 200 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ const signal = new AbortController().signal;
+ await client.getComparisonJobResult({
+ signal,
+ comparisonJob: {
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Completed",
+ iTwinId,
+ iModelId,
+ comparison: {
+ href: "https://example.test/results",
+ },
+ },
+ });
+
+ const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(request.signal).toBe(signal);
+ });
+
+ it("does not delete a fallback strategy job when preferred strategy is not found", async () => {
+ vi.mocked(iModelsClient.getChangeset)
+ .mockResolvedValueOnce(createChangeset(startChangesetId, 4))
+ .mockResolvedValueOnce(createChangeset(endChangesetId, 8));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ jobs: [] }), { status: 200 }));
+
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ jobs: [{
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "Queued",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ diffingPlan: { strategy: "Full" },
+ }],
+ }), { status: 200 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ diffingStrategy: "VersionCompare",
+ });
+
+ await expect(client.deleteComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: `${startChangesetId}-${endChangesetId}`,
+ })).rejects.toMatchObject({ code: "ComparisonNotFound" });
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
+ expect(firstUrl).toContain("diffingStrategy=VersionCompare");
+ expect(secondUrl).toContain("/diff?");
+ });
+
+ it("does not mask iModelsClient failures while resolving composite ids", async () => {
+ vi.mocked(iModelsClient.getChangeset).mockRejectedValue(new Error("iModels unavailable"));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ await expect(client.getComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: `${startChangesetId}-${endChangesetId}`,
+ })).rejects.toThrow("iModels unavailable");
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("throws on unsupported status values returned by API", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
+ job: {
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ status: "UnknownFutureStatus",
+ iTwinId,
+ iModelId,
+ startChangesetIndex: 4,
+ endChangesetIndex: 8,
+ },
+ }), { status: 200 }));
+
+ const client = new DiffJobClient({
+ baseUrl: "https://api.bentley.com/changedelements",
+ getAccessToken: async () => "Bearer token",
+ iModelsClient,
+ });
+
+ await expect(client.getComparisonJob({
+ iTwinId,
+ iModelId,
+ jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ })).rejects.toThrow("unsupported diff job status");
+ });
+});
diff --git a/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx b/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx
index 923921e9..646725b2 100644
--- a/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx
+++ b/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx
@@ -20,6 +20,7 @@ import { VersionCompareManager } from "../api/VersionCompareManager.js";
import { CenteredDiv } from "../common/CenteredDiv.js";
import { EmptyStateComponent } from "../common/EmptyStateComponent.js";
import { Widget as WidgetComponent } from "../common/Widget/Widget.js";
+import type { ChangedElementsApiVersion } from "../clients/IComparisonJobClient.js";
import { PropertyLabelCache } from "../dialogs/PropertyLabelCache.js";
import { ReportGeneratorDialog } from "../dialogs/ReportGeneratorDialog.js";
import { ChangedElementsInspector } from "./EnhancedElementsInspector.js";
@@ -39,6 +40,16 @@ import { Documentation } from "./Documentation.js";
export const changedElementsWidgetAttachToViewportEvent = new BeEvent<(vp: ScreenViewport) => void>();
+export type ChangedElementsWidgetApiVersion = "v1" | ChangedElementsApiVersion;
+
+const resolveWidgetApiVersion = (props: Pick): ChangedElementsWidgetApiVersion => {
+ if (props.apiVersion) {
+ return props.apiVersion;
+ }
+
+ return props.useV2Widget ? "v2" : "v1";
+};
+
/** Props for changed elements widget. */
export interface ChangedElementsWidgetProps {
/** IModel Connection that is being visualized. */
@@ -57,13 +68,18 @@ export interface ChangedElementsWidgetProps {
/**
* Optional. If true will use v2 dialog and will run comparison jobs for faster
* comparisons.
- * @beta
+ * @deprecated Use `apiVersion` instead.
*/
useV2Widget?: boolean;
+ /**
+ * Optional. Controls which Changed Elements API workflow the widget should use.
+ * If not provided, legacy `useV2Widget` is used for compatibility.
+ */
+ apiVersion?: ChangedElementsWidgetApiVersion;
+
/**
* Optional. If set information button will show documentation link.
- * @beta
*/
documentationHref?: string;
@@ -74,17 +90,17 @@ export interface ChangedElementsWidgetProps {
*/
usingExperimentalSelector?: boolean;
- /** Optional. Supply a link for feedback. Should only be used if v2 is enabled. */
+ /** Optional. Supply a link for feedback. Should only be used in comparison-job mode (v2/v3). */
feedbackUrl?: string;
/**
- * Optional. When enabled will toast messages regarding job status. If not defined
- * will default to false and will not show toasts (Only for V2).
+ * Optional. When enabled will toast messages regarding job status. If not defined
+ * will default to false and will not show toasts (comparison-job mode only).
*/
enableComparisonJobUpdateToasts?: boolean;
/**
- * On Job Update (Only for V2). Optional. A callback function for handling job
+ * On Job Update (comparison-job mode only). Optional. A callback function for handling job
* updates.
*
* @param comparisonJobUpdateType param for the type of update:
@@ -148,9 +164,10 @@ export class ChangedElementsWidget extends Component {
+ const isComparisonJobWidget = resolveWidgetApiVersion(this.props) !== "v1";
this.setState({
message: IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.comparisonNotActive"),
- description: this.props.useV2Widget
+ description: isComparisonJobWidget
? IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.versionCompareGettingStartedV2")
: IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.comparisonGetStarted"),
loading: false,
@@ -215,7 +232,7 @@ export class ChangedElementsWidget extends Component => {
+ const resolvedApiVersion = resolveWidgetApiVersion(this.props);
+ if (resolvedApiVersion === "v3") {
+ this.state.manager.featureTracking?.trackVersionSelectorV3Usage?.();
+ }
+
this.setState({ versionSelectDialogVisible: true });
};
@@ -392,6 +414,9 @@ export class ChangedElementsWidget extends Component
@@ -408,7 +433,7 @@ export class ChangedElementsWidget extends Component
{
- this.props.useV2Widget && this.props.feedbackUrl &&
+ isComparisonJobWidget && this.props.feedbackUrl &&
}
{
- this.props.useV2Widget
+ isComparisonJobWidget
? (
diff --git a/packages/changed-elements-react/src/widgets/VersionCompareSelectWidget.tsx b/packages/changed-elements-react/src/widgets/VersionCompareSelectWidget.tsx
index f9f14243..2755d1e4 100644
--- a/packages/changed-elements-react/src/widgets/VersionCompareSelectWidget.tsx
+++ b/packages/changed-elements-react/src/widgets/VersionCompareSelectWidget.tsx
@@ -3,7 +3,7 @@
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { Logger } from "@itwin/core-bentley";
-import { IModelApp, IModelConnection } from "@itwin/core-frontend";
+import { IModelApp, IModelConnection, NotifyMessageDetails, OutputMessagePriority } from "@itwin/core-frontend";
import {
Button, Modal, ModalButtonBar, ModalContent, ProgressLinear, ProgressRadial, Radio, Text
} from "@itwin/itwinui-react";
@@ -185,11 +185,35 @@ function usePagedNamedVersionLoader(
let disposed = false;
void (async () => {
- const [namedVersions, changesets] = await Promise.all([
- iModelsClient.getNamedVersions({ iModelId }),
- // Changesets need to be in descending index order
- iModelsClient.getChangesets({ iModelId }).then((changesets) => changesets.slice().reverse()),
- ]);
+ let namedVersions: NamedVersion[];
+ let changesets: Changeset[];
+ try {
+ [namedVersions, changesets] = await Promise.all([
+ iModelsClient.getNamedVersions({ iModelId }),
+ // Changesets need to be in descending index order
+ iModelsClient.getChangesets({ iModelId }).then((changesets) => changesets.slice().reverse()),
+ ]);
+ } catch (error) {
+ if (disposed) {
+ return;
+ }
+
+ const errorMessage = error instanceof Error ? error.message : "Unknown Error";
+ Logger.logError(VersionCompare.logCategory, `Could not load named versions and changesets: ${errorMessage}`);
+ IModelApp.notifications.outputMessage(
+ new NotifyMessageDetails(
+ OutputMessagePriority.Error,
+ IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error_versionCompare"),
+ IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error_cantLoadNamedVersions"),
+ ),
+ );
+ setResult({
+ namedVersions: { entries: [], currentVersion: undefined },
+ changesets: [],
+ });
+ return;
+ }
+
if (disposed) {
return;
}
diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareV2WidgetUtils.ts b/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareV2WidgetUtils.ts
index e7824d86..3153ac8b 100644
--- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareV2WidgetUtils.ts
+++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareV2WidgetUtils.ts
@@ -3,7 +3,7 @@
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { Logger } from "@itwin/core-bentley";
-import type { IModelConnection } from "@itwin/core-frontend";
+import { IModelApp, NotifyMessageDetails, OutputMessagePriority, type IModelConnection } from "@itwin/core-frontend";
import { VersionCompare } from "../../../api/VersionCompare.js";
import type { ComparisonJobCompleted, ComparisonJobStarted, IComparisonJobClient } from "../../../clients/IComparisonJobClient.js";
import type { IModelsClient, NamedVersion } from "../../../clients/iModelsClient.js";
@@ -23,7 +23,7 @@ export type ManagerStartComparisonV2Args = {
iModelsClient: IModelsClient;
};
-export const runManagerStartComparisonV2 = async (args: ManagerStartComparisonV2Args) => {
+export const runManagerStartComparisonV2 = async (args: ManagerStartComparisonV2Args): Promise => {
if (VersionCompare.manager?.isComparing) {
await VersionCompare.manager?.stopComparison();
}
@@ -43,14 +43,14 @@ export const runManagerStartComparisonV2 = async (args: ManagerStartComparisonV2
const manager = VersionCompare.manager;
if (!manager) {
Logger.logError("VersionCompare", "VersionCompare manager is not initialized");
- return;
+ return false;
}
try {
const targetVersion = await updateTargetVersion(args.iModelConnection, args.targetVersion, args.iModelsClient);
if (VersionCompare.changesProvider) {
await manager.startDirectComparison(args.iModelConnection, args.currentVersion, targetVersion);
- return;
+ return true;
}
const changedElements = await args.comparisonJobClient.getComparisonJobResult(args.comparisonJob);
@@ -60,10 +60,17 @@ export const runManagerStartComparisonV2 = async (args: ManagerStartComparisonV2
await updateTargetVersion(args.iModelConnection, args.targetVersion, args.iModelsClient),
[changedElements.changedElements],
);
+ return true;
} catch (error: unknown) {
- if (error instanceof Error) {
- Logger.logError("VersionCompare", `Error starting comparison: ${error.message}`);
- }
+ const errorMessage = error instanceof Error ? error.message : "Unknown Error";
+ Logger.logError("VersionCompare", `Error starting comparison: ${errorMessage}`);
+
+ const briefError = IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error_versionCompare");
+ const detailed = IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error_cantStart");
+ IModelApp.notifications.outputMessage(
+ new NotifyMessageDetails(OutputMessagePriority.Error, briefError, `${detailed}: ${errorMessage}`),
+ );
+ return false;
}
};
diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx
index 043f8ac4..e9aad9c7 100644
--- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx
+++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx
@@ -9,7 +9,7 @@ import React, { useEffect, useState, type ReactNode } from "react";
import { VersionCompareUtils, VersionCompareVerboseMessages } from "../../../api/VerboseMessages.js";
import { VersionCompare } from "../../../api/VersionCompare.js";
import type {
- ComparisonJob, ComparisonJobCompleted, IComparisonJobClient
+ ChangedElementsApiVersion, ComparisonJob, ComparisonJobCompleted, IComparisonJobClient
} from "../../../clients/IComparisonJobClient.js";
import type { IModelsClient, NamedVersion } from "../../../clients/iModelsClient.js";
import { arrayToMap, tryXTimes } from "../../../utils/utils.js";
@@ -34,6 +34,8 @@ import "./styles/ComparisonJobWidget.scss";
export interface VersionCompareSelectDialogV2Props {
/** IModel Connection that is being visualized. */
iModelConnection: IModelConnection;
+ /** Comparison API version expected by the current widget mode. */
+ apiVersion: ChangedElementsApiVersion;
/** onClose triggered when user clicks start comparison or closes dialog.*/
onClose: (() => void) | undefined;
"data-testid"?: string;
@@ -61,10 +63,13 @@ export interface VersionCompareSelectDialogV2Props {
export function VersionCompareSelectDialogV2(props: VersionCompareSelectDialogV2Props) {
const { comparisonJobClient, iModelsClient } = useVersionCompare();
if (!comparisonJobClient) {
- throw new Error("V2 Client Is Not Initialized In Given Context.");
+ throw new Error("Comparison job client is not initialized in the current VersionCompareContext.");
+ }
+ if (comparisonJobClient.apiVersion !== props.apiVersion) {
+ throw new Error(`Comparison job client apiVersion mismatch. Expected '${props.apiVersion}', got '${comparisonJobClient.apiVersion}'.`);
}
if (!iModelsClient) {
- throw new Error("V1 Client Is Not Initialized In Given Context.");
+ throw new Error("iModels client is not initialized in the current VersionCompareContext.");
}
const { openDialog, closedDialog, getDialogOpen, addRunningJob, removeRunningJob, getRunningJobs
, getPendingJobs, removePendingJob, addPendingJob, getToastsEnabled, runOnJobUpdate } = React.useContext(V2DialogContext);
@@ -290,7 +295,8 @@ type handleJobErrorArgs = Omit Promise = async (args) => {
- args.addPendingJob(args.comparisonJob.comparisonJob.jobId, {
+ const pendingJobId = createJobId(args.targetVersion, args.currentVersion);
+ args.addPendingJob(pendingJobId, {
targetNamedVersion: args.targetVersion,
currentNamedVersion: args.currentVersion,
});
@@ -307,7 +313,7 @@ const handleJobError: (args: handleJobErrorArgs) => Promise = asy
startChangesetId: args.targetVersion.changesetId as string,
endChangesetId: args.currentVersion.changesetId as string,
}));
- args.removePendingJob(job.comparisonJob.jobId);
+ args.removePendingJob(pendingJobId);
return job;
}, 3);
};
@@ -350,7 +356,7 @@ const pollUntilCurrentRunningJobsCompleteAndToast = async (args: PollForInProgre
jobId: runningJob?.comparisonJob?.comparisonJob.jobId as string,
});
if (completedJob.comparisonJob.status === "Failed") {
- args.removeRunningJob(runningJob?.comparisonJob?.comparisonJob.jobId as string);
+ args.removeRunningJob(createJobId(runningJob.targetNamedVersion, runningJob.currentNamedVersion));
continue;
}
notifyComparisonCompletion({
@@ -368,7 +374,7 @@ const pollUntilCurrentRunningJobsCompleteAndToast = async (args: PollForInProgre
toaster: args.toaster,
});
} catch (error) {
- args.removeRunningJob(runningJob?.comparisonJob?.comparisonJob.jobId as string);
+ args.removeRunningJob(createJobId(runningJob.targetNamedVersion, runningJob.currentNamedVersion));
throw error;
}
}
@@ -400,7 +406,7 @@ type ConditionallyToastCompletionArgs = {
};
const notifyComparisonCompletion = (args: ConditionallyToastCompletionArgs) => {
if (args.currentJobRsp.comparisonJob.status === "Completed") {
- args.removeRunningJob(args.runningJob?.comparisonJob?.comparisonJob.jobId as string);
+ args.removeRunningJob(createJobId(args.runningJob.targetNamedVersion, args.runningJob.currentNamedVersion));
if (!VersionCompare.manager?.isComparing && !args.getDialogOpen()) {
if (args.getToastsEnabled()) {
toastComparisonJobComplete({
@@ -428,7 +434,7 @@ const notifyComparisonCompletion = (args: ConditionallyToastCompletionArgs) => {
const pollUpdateCurrentEntriesForModal = async (args: PollForInProgressJobsArgs) => {
const currentVersionId = args.iModelConnection?.changeset.id;
let entries = args.namedVersionLoaderState!.namedVersions.entries.slice();
- const currentRunningJobsMap = arrayToMap(args.getRunningJobs(), (job: JobAndNamedVersions) => { return job.comparisonJob?.comparisonJob.jobId as string; });
+ const currentRunningJobsMap = arrayToMap(args.getRunningJobs(), (job: JobAndNamedVersions) => { return createJobId(job.targetNamedVersion, job.currentNamedVersion); });
if (areJobsInProgress(entries, args.getRunningJobs)) {
const idEntryMap = arrayToMap(entries, (entry: VersionState) => { return entry.version.id; });
let updatingEntries = getUpdatingEntries(entries, currentVersionId, currentRunningJobsMap);
diff --git a/packages/test-app-frontend/.env b/packages/test-app-frontend/.env
index 0abd0973..4790588c 100644
--- a/packages/test-app-frontend/.env
+++ b/packages/test-app-frontend/.env
@@ -30,3 +30,7 @@ VITE_RUN_EXPERIMENTAL=true
# Set to "true" to enable direct comparison mode, which uses the ChangesRpcInterface to get changes from backend instead of service
VITE_USE_DIRECT_COMPARISON=
+
+# Set to "true" to enable V3 API. If "false", V2 API will be used. Direct comparison mode
+# (VITE_USE_DIRECT_COMPARISON) does not require this flag; it is independent of API version.
+VITE_USE_V3_COMPARISON=
diff --git a/packages/test-app-frontend/src/App/ITwinJsApp/ITwinJsApp.tsx b/packages/test-app-frontend/src/App/ITwinJsApp/ITwinJsApp.tsx
index 683b8ca1..7999f97c 100644
--- a/packages/test-app-frontend/src/App/ITwinJsApp/ITwinJsApp.tsx
+++ b/packages/test-app-frontend/src/App/ITwinJsApp/ITwinJsApp.tsx
@@ -6,13 +6,14 @@
import {
AppNotificationManager, ConfigurableUiContent,
FrontstageUtilities, IModelViewportControl, ReducerRegistryInstance,
- StagePanelLocation, StagePanelSection, StagePanelState, StageUsage,
+ StagePanelLocation, StagePanelSection, StagePanelState, StageUsage,
UiFramework, UiItemsManager, type UiItemsProvider, type Widget
} from "@itwin/appui-react";
import {
ChangedECInstance,
ChangedElementsWidget,
ComparisonJobClient,
+ DiffJobClient,
ITwinIModelsClient,
NamedVersionSelectorWidget,
VersionCompare,
@@ -37,7 +38,7 @@ import { PresentationRpcInterface } from "@itwin/presentation-common";
import { Presentation } from "@itwin/presentation-frontend";
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { ChangesRpcInterface, RelationshipClassWithDirection } from "../../../../test-app-backend/src/RPC/ChangesRpcInterface.js";
-import { applyUrlPrefix, localBackendPort, runExperimental, useDirectComparison, usingLocalBackend } from "../../environment.js";
+import { applyUrlPrefix, localBackendPort, runExperimental, useDirectComparison, useV3Comparison, usingLocalBackend } from "../../environment.js";
import { LoadingScreen } from "../common/LoadingScreen.js";
import { AppUiVisualizationHandler } from "./AppUi/AppUiVisualizationHandler.js";
import { UIFramework } from "./AppUi/UiFramework.js";
@@ -119,12 +120,21 @@ export function ITwinJsApp(props: ITwinJsAppProps): ReactElement | null {
const comparisonJobClient = useMemo(
() => {
+ if (useV3Comparison) {
+ return new DiffJobClient({
+ baseUrl: applyUrlPrefix("https://api.bentley.com/changedelements"),
+ getAccessToken: VersionCompare.getAccessToken,
+ iModelsClient,
+ diffingStrategy: "VersionCompare",
+ });
+ }
+
return new ComparisonJobClient({
baseUrl: applyUrlPrefix("https://api.bentley.com/changedelements"),
getAccessToken: VersionCompare.getAccessToken,
});
},
- [],
+ [iModelsClient],
);
if (loadingState === "opening-imodel") {
@@ -155,6 +165,7 @@ const savedFilters = new MockSavedFiltersManager();
/** Simple console log testing functions for feature tracking implementation */
const featureTrackingTesterFunctions: VersionCompareFeatureTracking = {
trackVersionSelectorV2Usage: () => { console.log("trackVersionSelectorV2Usage"); },
+ trackVersionSelectorV3Usage: () => { console.log("trackVersionSelectorV3Usage"); },
trackVersionSelectorUsage: () => { console.log("trackVersionSelectorUsage"); },
trackPropertyComparisonUsage: () => { console.log("trackPropertyComparisonUsage"); },
trackChangeReportGenerationUsage: () => { console.log("trackChangeReportGenerationUsage"); },
@@ -359,7 +370,7 @@ class MainFrontstageItemsProvider implements UiItemsProvider {
return [{
id: "ChangedElementsWidget",
content: =3.4.11'
'@google-cloud/storage@<7.21.0': '>=7.21.0'
uuid@<11.1.1: '>=11.1.1'
+ linkify-it@<=5.0.0: '>=5.0.1'
+ js-yaml@<3.15.0: '>=3.15.0'
+ read-yaml-file>js-yaml: ^3.14.1
importers:
@@ -3071,8 +3074,8 @@ packages:
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
- js-yaml@3.14.2:
- resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
+ js-yaml@3.15.0:
+ resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
hasBin: true
js-yaml@4.2.0:
@@ -3202,8 +3205,8 @@ packages:
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
- linkify-it@2.2.0:
- resolution: {integrity: sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==}
+ linkify-it@5.0.2:
+ resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
load-json-file@4.0.0:
resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
@@ -4270,8 +4273,8 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
- uc.micro@1.0.6:
- resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
+ uc.micro@2.1.0:
+ resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
uid-safe@2.1.5:
resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==}
@@ -5151,7 +5154,7 @@ snapshots:
'@itwin/itwinui-react': 3.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
classnames: 2.5.1
immer: 10.1.1
- linkify-it: 2.2.0
+ linkify-it: 5.0.2
lodash: 4.18.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -5168,7 +5171,7 @@ snapshots:
'@itwin/itwinui-react': 3.19.4(react-dom@18.0.0(react@18.3.1))(react@18.3.1)
classnames: 2.5.1
immer: 10.1.1
- linkify-it: 2.2.0
+ linkify-it: 5.0.2
lodash: 4.18.1
react: 18.3.1
react-dom: 18.0.0(react@18.3.1)
@@ -7803,7 +7806,7 @@ snapshots:
js-tokens@9.0.1: {}
- js-yaml@3.14.2:
+ js-yaml@3.15.0:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
@@ -7920,9 +7923,9 @@ snapshots:
base64-js: 0.0.8
unicode-trie: 2.0.0
- linkify-it@2.2.0:
+ linkify-it@5.0.2:
dependencies:
- uc.micro: 1.0.6
+ uc.micro: 2.1.0
load-json-file@4.0.0:
dependencies:
@@ -8440,7 +8443,7 @@ snapshots:
read-yaml-file@1.1.0:
dependencies:
graceful-fs: 4.2.11
- js-yaml: 3.14.2
+ js-yaml: 3.15.0
pify: 4.0.1
strip-bom: 3.0.0
@@ -9071,7 +9074,7 @@ snapshots:
typescript@5.5.4: {}
- uc.micro@1.0.6: {}
+ uc.micro@2.1.0: {}
uid-safe@2.1.5:
dependencies: