diff --git a/.changeset/moody-hounds-fail.md b/.changeset/moody-hounds-fail.md new file mode 100644 index 00000000..240c3e9f --- /dev/null +++ b/.changeset/moody-hounds-fail.md @@ -0,0 +1,15 @@ +--- +"@itwin/changed-elements-react": major +--- + +Add support for Changed Elements API v3 alongside v1/v2. + +**Breaking changes:** +- `IComparisonJobClient` now requires `readonly apiVersion: "v2" | "v3"` field. Custom client implementations must add this property. + +**New features:** +- `DiffJobClient` class for v3 API (`/diff`) with changeset Id resolution and progress tracking. +- `apiVersion` prop on `ChangedElementsWidget` to select API workflow (v1/v2/v3). Replaces reliance on `useV2Widget`. + +**Deprecations:** +- `useV2Widget` prop on `ChangedElementsWidget` is now deprecated in favor of `apiVersion`. diff --git a/package.json b/package.json index b13f1d7c..91e5487d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,10 @@ "form-data@<4.0.6": ">=4.0.6", "dompurify@<=3.4.10": ">=3.4.11", "@google-cloud/storage@<7.21.0": ">=7.21.0", - "uuid@<11.1.1": ">=11.1.1" + "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" } }, "devDependencies": { diff --git a/packages/changed-elements-react/README.md b/packages/changed-elements-react/README.md index 8ec3f9b1..77cf5a04 100644 --- a/packages/changed-elements-react/README.md +++ b/packages/changed-elements-react/README.md @@ -2,7 +2,12 @@ ## About -This package provides React components that help implement iTwin version comparison workflows. These components are designed to communicate with [iTwin Platform Changed Elements API](https://developer.bentley.com/apis/changed-elements-v2), which is used to retrieve data about iModel change history. +This package provides React components that help implement iTwin version comparison workflows. These components are designed to communicate with iTwin Platform Changed Elements APIs (v1/v2/v3) to retrieve data about iModel change history. + +API Documentation: +- [V3 API Documentation - Tech Preview](https://developer.bentley.com/apis/changed-elements-v3/) +- [V2 API Documentation](https://developer.bentley.com/apis/changed-elements-v2/) +- [V1 API Documentation - DEPRECATED](https://developer.bentley.com/apis/changed-elements/) ## Installation @@ -18,9 +23,14 @@ To begin using this package in your application, you will need to: 2. Provide `` somewhere in your app. ```tsx - import { VersionCompareContext } from "@itwin/changed-elements-react"; + import { ComparisonJobClient, VersionCompare, VersionCompareContext } from "@itwin/changed-elements-react"; + + const comparisonJobClient = new ComparisonJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: VersionCompare.getAccessToken, + }); - + ``` @@ -41,7 +51,7 @@ To begin using this package in your application, you will need to: import { ChangedElementsWidget } from "@itwin/changed-elements-react"; , ``` +### V3 setup + +Use `DiffJobClient` in `VersionCompareContext` and set `apiVersion="v3"` on `ChangedElementsWidget`. + +```tsx +import { DiffJobClient, VersionCompare, VersionCompareContext } from "@itwin/changed-elements-react"; + +const comparisonJobClient = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: VersionCompare.getAccessToken, + iModelsClient: iTwinIModelsClient, + diffingStrategy: "VersionCompare", +}); + + + + +``` + +`useV2Widget` is still supported for backward compatibility, but `apiVersion` is the preferred prop moving forward. + 5. The `` is an **experimental** React component that lets users inspect differences in properties between versions, generate reports, search for changed elements, and control element visibility. The following code shows an example widget initialization (does not show off all props). ```ts diff --git a/packages/changed-elements-react/public/locales/en/VersionCompare.json b/packages/changed-elements-react/public/locales/en/VersionCompare.json index 7c40e1ca..965d7f15 100644 --- a/packages/changed-elements-react/public/locales/en/VersionCompare.json +++ b/packages/changed-elements-react/public/locales/en/VersionCompare.json @@ -74,6 +74,7 @@ "error_versionCompare": "Version Compare Error", "error_cantStart": "Could not start comparison against version", "error_invalidToken": "Could not authenticate user for version compare usage", + "error_cantLoadNamedVersions": "Could not load named versions and changesets", "msg_openingTarget": "Opening target iModel", "msg_getChangedElements": "Requesting changed elements", "msg_initializingComparison": "Initializing comparison", diff --git a/packages/changed-elements-react/src/NamedVersionSelector/NamedVersionSelector.tsx b/packages/changed-elements-react/src/NamedVersionSelector/NamedVersionSelector.tsx index 2d016eab..e98da647 100644 --- a/packages/changed-elements-react/src/NamedVersionSelector/NamedVersionSelector.tsx +++ b/packages/changed-elements-react/src/NamedVersionSelector/NamedVersionSelector.tsx @@ -124,7 +124,7 @@ export function NamedVersionSelectorWidget(props: Readonly true, iModelsClient, }); + if (!started) { + setDisableStartComparison(false); + } }; const stopComparisonCallback = useCallback(async () => { diff --git a/packages/changed-elements-react/src/ResizeObserver.tsx b/packages/changed-elements-react/src/ResizeObserver.tsx index 8f4ef889..2ce53c30 100644 --- a/packages/changed-elements-react/src/ResizeObserver.tsx +++ b/packages/changed-elements-react/src/ResizeObserver.tsx @@ -113,7 +113,7 @@ export interface ResizeObserverWrapperProps extends Omit( function ResizeObserverWrapper(props, ref) { - const divRef = useRef(null as unknown as HTMLDivElement); + const divRef = useRef(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: