diff --git a/.vscode/settings.json b/.vscode/settings.json index 541f6f77..087cbae5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,6 +23,13 @@ "typescript.format.semicolons": "insert", "typescript.preferences.importModuleSpecifierEnding": "js", + "typescript.preferences.preferTypeOnlyAutoImports": true, + "typescript.preferences.organizeImports": { + "caseFirst": "lower", + "caseSensitivity": "caseSensitive", + "numericCollation": true, + "typeOrder": "last", + }, "typescript.reportStyleChecksAsWarnings": true, "[javascript][typescript][typescriptreact]": { diff --git a/packages/changed-elements-react/src/api/VersionCompareManager.ts b/packages/changed-elements-react/src/api/VersionCompareManager.ts index 9a60cec8..393d31d0 100644 --- a/packages/changed-elements-react/src/api/VersionCompareManager.ts +++ b/packages/changed-elements-react/src/api/VersionCompareManager.ts @@ -279,9 +279,11 @@ export class VersionCompareManager { IModelVersion.asOfChangeSet(changesetId), ); - // Keep metadata around for UI uses and other queries - this.currentVersion = currentVersion; - this.targetVersion = targetVersion; + // Keep metadata around for UI uses and other queries. We may receive an + // immutable React state, thus a copy is needed in case user ever atttempts + // to mutate the objects. + this.currentVersion = structuredClone(currentVersion); + this.targetVersion = structuredClone(targetVersion); this.loadingProgressEvent.raiseEvent( IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.msg_getChangedElements"), @@ -414,9 +416,11 @@ export class VersionCompareManager { IModelVersion.asOfChangeSet(changesetId), ); - // Keep metadata around for UI uses and other queries - this.currentVersion = currentVersion; - this.targetVersion = targetVersion; + // Keep metadata around for UI uses and other queries. We may receive an + // immutable React state, thus a copy is needed in case user ever atttempts + // to mutate the objects. + this.currentVersion = structuredClone(currentVersion); + this.targetVersion = structuredClone(targetVersion); this.loadingProgressEvent.raiseEvent( IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.msg_getChangedElements"), diff --git a/packages/changed-elements-react/src/index.ts b/packages/changed-elements-react/src/index.ts index 63044185..51c9b24b 100644 --- a/packages/changed-elements-react/src/index.ts +++ b/packages/changed-elements-react/src/index.ts @@ -2,8 +2,6 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -export { type FilterData, type FilterOptions, type SavedFiltersManager } from "./SavedFiltersManager.js"; -export { VersionCompareContext, type VersionCompareContextValue } from "./VersionCompareContext.js"; export { type ChangedElementEntry } from "./api/ChangedElementEntryCache.js"; export * from "./api/ChangedElementsApiClient.js"; export * from "./api/ChangedElementsClientBase.js"; @@ -16,17 +14,27 @@ export { VersionCompareManager } from "./api/VersionCompareManager.js"; export * from "./api/VersionCompareTiles.js"; export * from "./api/VersionCompareVisualization.js"; export type { MainVisualizationOptions, VisualizationHandler } from "./api/VisualizationHandler.js"; +export { + ComparisonJobClient, type ComparisonJobClientParams, +} from "./clients/ComparisonJobClient.js"; +export type { + ComparisonJob, ComparisonJobCompleted, ComparisonJobFailed, ComparisonJobQueued, + ComparisonJobStarted, +} from "./clients/IComparisonJobClient.js"; export type { Changeset, GetChangesetsParams, GetNamedVersionsParams, IModelsClient, NamedVersion, } from "./clients/iModelsClient.js"; export { ITwinIModelsClient, type ITwinIModelsClientParams } from "./clients/iTwinIModelsClient.js"; -export { ComparisonJobClient, type ComparisonJobClientParams } from "./clients/ComparisonJobClient.js"; export * from "./contentviews/PropertyComparisonTable.js"; +export type { FilterData, FilterOptions, SavedFiltersManager } from "./SavedFiltersManager.js"; +export { VersionCompareContext, type VersionCompareContextValue } from "./VersionCompareContext.js"; export * from "./widgets/ChangedElementsWidget.js"; +export * from "./widgets/comparisonJobWidget/VersionCompareDialogProvider.js"; +export { + VersionCompareSelectDialogV2, +} from "./widgets/comparisonJobWidget/VersionCompareSelectDialogV2.js"; +export type { JobAndNamedVersions } from "./widgets/comparisonJobWidget/NamedVersions.js"; +export { pollForInProgressJobs } from "./widgets/comparisonJobWidget/useNamedVersionLoader.js"; +export * from "./widgets/comparisonJobWidget/versionCompareToasts.js"; export { ChangedElementsListComponent } from "./widgets/EnhancedElementsInspector.js"; export * from "./widgets/VersionCompareSelectWidget.js"; -export * from "./widgets/comparisonJobWidget/components/VersionCompareSelectModal.js" -export * from "./widgets/comparisonJobWidget/components/VersionCompareDialogProvider.js" -export * from "./widgets/comparisonJobWidget/common/versionCompareToasts.js" -export type {JobAndNamedVersions} from "./widgets/comparisonJobWidget/models/ComparisonJobModels.js" -export type {ComparisonJob, ComparisonJobCompleted, ComparisonJobFailed, ComparisonJobQueued, ComparisonJobStarted} from "./clients/IComparisonJobClient.js"; diff --git a/packages/changed-elements-react/src/utils/utils.ts b/packages/changed-elements-react/src/utils/utils.ts index 5c8a4d7c..3a9a328c 100644 --- a/packages/changed-elements-react/src/utils/utils.ts +++ b/packages/changed-elements-react/src/utils/utils.ts @@ -69,15 +69,3 @@ export async function tryXTimes(func: () => Promise, attempts: number, del } throw error; } - -/** - Creates a map from an array of values. - * Expects createKey to supply a unique key per entry; otherwise will cause other entries with same key to be overwritten. - */ -export const arrayToMap = (array: T[], createKey: (entry: T) => U) => { - const newMap = new Map(); - array.forEach((entry) => { - newMap.set(createKey(entry), entry); - }); - return newMap; -}; diff --git a/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx b/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx index beb6e813..bf0647d3 100644 --- a/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx +++ b/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx @@ -4,30 +4,37 @@ *--------------------------------------------------------------------------------------------*/ import { BeEvent, Logger, type Id64String } from "@itwin/core-bentley"; import { - IModelApp, IModelConnection, NotifyMessageDetails, OutputMessagePriority, ScreenViewport + IModelApp, NotifyMessageDetails, OutputMessagePriority, type IModelConnection, + type ScreenViewport } from "@itwin/core-frontend"; import { SvgAdd, SvgCompare, SvgExport, SvgStop } from "@itwin/itwinui-icons-react"; import { IconButton, ProgressRadial } from "@itwin/itwinui-react"; -import { Component, ReactElement, ReactNode } from "react"; -import { FilterOptions } from "../SavedFiltersManager.js"; -import { type ChangedElementEntry } from "../api/ChangedElementEntryCache.js"; -import { ReportProperty } from "../api/ReportGenerator.js"; +import { Component, type ReactElement, type ReactNode } from "react"; + +import type { FilterOptions } from "../SavedFiltersManager.js"; +import type { ChangedElementEntry } from "../api/ChangedElementEntryCache.js"; +import type { ReportProperty } from "../api/ReportGenerator.js"; import { VersionCompareUtils, VersionCompareVerboseMessages } from "../api/VerboseMessages.js"; import { VersionCompare } from "../api/VersionCompare.js"; -import { VersionCompareManager } from "../api/VersionCompareManager.js"; +import type { 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 { PropertyLabelCache } from "../dialogs/PropertyLabelCache.js"; import { ReportGeneratorDialog } from "../dialogs/ReportGeneratorDialog.js"; import { ChangedElementsInspector } from "./EnhancedElementsInspector.js"; -import "./ChangedElementsWidget.scss"; -import InfoButton from "./InformationButton.js"; -import { VersionCompareSelectDialogV2 } from "./comparisonJobWidget/components/VersionCompareSelectModal.js"; import { FeedbackButton } from "./FeedbackButton.js"; +import InfoButton from "./InformationButton.js"; import { VersionCompareSelectDialog } from "./VersionCompareSelectWidget.js"; -import { ComparisonJobUpdateType, VersionCompareSelectProviderV2 } from "./comparisonJobWidget/components/VersionCompareDialogProvider.js"; -import { JobAndNamedVersions } from "./comparisonJobWidget/models/ComparisonJobModels.js"; +import type { JobAndNamedVersions } from "./comparisonJobWidget/NamedVersions.js"; +import { + VersionCompareSelectProviderV2, type ComparisonJobUpdateType +} from "./comparisonJobWidget/VersionCompareDialogProvider.js"; +import { + VersionCompareSelectDialogV2 +} from "./comparisonJobWidget/VersionCompareSelectDialogV2.js"; + +import "./ChangedElementsWidget.scss"; export const changedElementsWidgetAttachToViewportEvent = new BeEvent<(vp: ScreenViewport) => void>(); @@ -409,22 +416,33 @@ export class ChangedElementsWidget extends Component } - {this.props.useV2Widget ? - - {this.state.versionSelectDialogVisible && - } - : - this.state.versionSelectDialogVisible && - } + + { + this.state.versionSelectDialogVisible && + ( + this.props.useV2Widget + ? ( + + ) : ( + + ) + ) + } + ); } diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/ComparisonJobModels.ts b/packages/changed-elements-react/src/widgets/comparisonJobWidget/NamedVersions.ts similarity index 60% rename from packages/changed-elements-react/src/widgets/comparisonJobWidget/models/ComparisonJobModels.ts rename to packages/changed-elements-react/src/widgets/comparisonJobWidget/NamedVersions.ts index 0843c24c..a04125c9 100644 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/ComparisonJobModels.ts +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/NamedVersions.ts @@ -2,8 +2,33 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { ComparisonJob } from "../../../clients/IComparisonJobClient"; -import { NamedVersion } from "../../../clients/iModelsClient"; +import type { ComparisonJob } from "../../clients/IComparisonJobClient.js"; +import type { NamedVersion } from "../../clients/iModelsClient.js"; + +/** + * Holds the version state of named versions and the current version. +*/ +export interface CurrentNamedVersionAndNamedVersions { + entries: NamedVersion[]; + versionState: VersionState[]; + currentVersion: NamedVersion | undefined; +} + +export type VersionState = { + jobId: string; + state: VersionProcessedState; + // nullable because we don't run jobs in V1. For v2 use only. + jobStatus?: JobStatus; + // nullable because we don't run jobs in V1. For v2 use only. + jobProgress?: JobProgress; +}; + +export enum VersionProcessedState { + Verifying, + Processed, + Processing, + Unavailable, +} /** * Job status used for identification of job progress @@ -17,26 +42,16 @@ import { NamedVersion } from "../../../clients/iModelsClient"; */ export type JobStatus = "Unknown" | "Available" | "Not Processed" | "Processing" | "Error" | "Queued"; -/** - * Used to display progress of a job. - * current progress / maximum progress. -*/ export type JobProgress = { currentProgress: number; maxProgress: number; }; -/** - * Holds both the job progress and job status. -*/ export type JobStatusAndJobProgress = { jobStatus: JobStatus; jobProgress: JobProgress; }; -/** - * Holds comparison job and its named versions. -*/ export type JobAndNamedVersions = { comparisonJob?: ComparisonJob; targetNamedVersion: NamedVersion; diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareDialogProvider.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareDialogProvider.tsx new file mode 100644 index 00000000..87abf64a --- /dev/null +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareDialogProvider.tsx @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +import { createContext, type ReactElement, type ReactNode, useRef } from "react"; + +import type { JobAndNamedVersions } from "./NamedVersions.js"; + +/** + * Comparison Job Update Type + * - "JobComplete" = job is completed + * - "JobError" = job error + * - "JobProcessing" = job is started + * - "ComparisonVisualizationStarting" = version compare visualization is starting + */ +export type ComparisonJobUpdateType = "JobComplete" | "JobError" | "JobProcessing" + | "ComparisonVisualizationStarting"; + +export interface V2Context { + addRunningJob: (jobId: string, comparisonJob: JobAndNamedVersions) => void; + removeRunningJob: (jobId: string) => void; + getRunningJobs: () => JobAndNamedVersions[]; + getPendingJobs: () => JobAndNamedVersions[]; + addPendingJob: (jobId: string, comparisonJob: JobAndNamedVersions) => void; + removePendingJob: (jobId: string) => void; + getToastsEnabled: () => boolean; + runOnJobUpdate: ( + comparisonJobUpdateType: ComparisonJobUpdateType, + jobAndNamedVersions?: JobAndNamedVersions, + ) => Promise; +} + +export const V2DialogContext = createContext({} as V2Context); + +export type V2DialogProviderProps = { + children: ReactNode; + + /** + * Optional. When enabled will toast messages regarding job status. If not defined, + * will default to false and will not show toasts. + */ + enableComparisonJobUpdateToasts?: boolean; + + /** + * Optional. A call back function for handling job updates. + * @param comparisonJobUpdateType param for the type of update: + * - "JobComplete" = invoked when job is completed + * - "JobError" = invoked on job error + * - "JobProcessing" = invoked on job is started + * - "ComparisonVisualizationStarting" = invoked on when version compare visualization is starting + * @param jobAndNamedVersion param contain job and named version info to be passed to call back + */ + onJobUpdate?: ( + comparisonJobUpdateType: ComparisonJobUpdateType, + jobAndNamedVersions?: JobAndNamedVersions, + ) => Promise; +}; + +/** + * V2DialogProvider use comparison jobs for processing. Used for tracking if the + * dialog is open or closed. This is useful for managing toast messages associated + * with dialog. Also caches comparison jobs that are pending creation or are currently + * running. To help populate new modal ref. + * + * @exmaple + * + * { + * isOpenCondition && + * + * } + * + */ +export function VersionCompareSelectProviderV2( + { children, enableComparisonJobUpdateToasts, onJobUpdate }: V2DialogProviderProps, +): ReactElement { + const dialogRunningJobs = useRef(new Map()); + const dialogPendingJobs = useRef(new Map()); + const addRunningJob = (jobId: string, jobAndNamedVersions: JobAndNamedVersions) => { + dialogRunningJobs.current.set(jobId, jobAndNamedVersions); + }; + const removeRunningJob = (jobId: string) => { + dialogRunningJobs.current.delete(jobId); + }; + const getRunningJobs = () => Array.from(dialogRunningJobs.current.values()); + const addPendingJob = (jobId: string, jobAndNamedVersions: JobAndNamedVersions) => { + dialogPendingJobs.current.set(jobId, jobAndNamedVersions); + }; + const removePendingJob = (jobId: string) => { + dialogPendingJobs.current.delete(jobId); + }; + const getPendingJobs = () => Array.from(dialogPendingJobs.current.values()); + + // This is a hack to get the most recent value of enableComparisonJobUpdateToasts + // in useNamedVersionLoader because the effects there don't list all their dependencies + const enableComparisonJobUpdateToastsRef = useRef(enableComparisonJobUpdateToasts); + enableComparisonJobUpdateToastsRef.current = enableComparisonJobUpdateToasts; + + const getToastsEnabled = () => enableComparisonJobUpdateToastsRef.current ?? false; + const runOnJobUpdate = async ( + comparisonEventType: ComparisonJobUpdateType, + jobAndNamedVersions?: JobAndNamedVersions, + ) => { + await onJobUpdate?.(comparisonEventType, jobAndNamedVersions); + }; + return ( + + {children} + + ); +} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareSelectComponent.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareSelectComponent.tsx new file mode 100644 index 00000000..63b71874 --- /dev/null +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareSelectComponent.tsx @@ -0,0 +1,363 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +import { IModelApp, type IModelConnection } from "@itwin/core-frontend"; +import { LoadingSpinner } from "@itwin/core-react"; +import { Badge, ProgressLinear, ProgressRadial, Radio, Text } from "@itwin/itwinui-react"; +import { useState, type ReactElement, type ReactNode } from "react"; + +import type { ChangesetChunk } from "../../api/ChangedElementsApiClient"; +import type { NamedVersion } from "../../clients/iModelsClient"; +import { + VersionProcessedState, type CurrentNamedVersionAndNamedVersions, type JobProgress, + type JobStatus, type VersionState +} from "./NamedVersions"; + +interface VersionCompareSelectorProps { + iModelConnection: IModelConnection; + onVersionSelected: ( + currentVersion: NamedVersion, + targetVersion: NamedVersion, + chunks?: ChangesetChunk[], + ) => void; + wantTitle?: boolean | undefined; + namedVersions?: CurrentNamedVersionAndNamedVersions | undefined; + manageNamedVersionsSlot?: ReactNode | undefined; + isLoading?: boolean | undefined; +} + +/** Component that lets the user select which named version to compare to. */ +export function VersionCompareSelectComponent(props: VersionCompareSelectorProps) { + const [targetVersion, setTargetVersion] = useState(); + + const handleVersionClicked = (targetVersion: NamedVersion) => { + setTargetVersion(targetVersion); + if (props.namedVersions && props.namedVersions.currentVersion) { + props.onVersionSelected?.(props.namedVersions.currentVersion, targetVersion); + } + }; + + if (!props.namedVersions) { + return ( +
+ +
+ ); + } + + return ( + + ); +} + +interface VersionCompareSelectorInnerProps { + entries: NamedVersion[]; + versionState: VersionState[]; + currentVersion?: NamedVersion | undefined; + selectedVersionChangesetId?: string | undefined; + onVersionClicked: (targetVersion: NamedVersion) => void; + wantTitle?: boolean | undefined; + manageNamedVersionsSlot?: ReactNode | undefined; + isLoading?: boolean | undefined; +} + +/** Component that houses named version list. Also displays the current versions information. */ +function VersionCompareSelectorInner(props: VersionCompareSelectorInnerProps): ReactElement { + return ( +
+ { + props.currentVersion && +
+
+ {`${IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.compare")}:`} +
+
+
+ + +
+ { + props.currentVersion.createdDateTime && + new Date(props.currentVersion.createdDateTime).toDateString() + } +
+
+ {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.current")} +
+
+
+
+
+ } + { + props.wantTitle && +
+ {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.versionCompare")} +
+ } + { +
+ {`${IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.withPrevious")}:`} +
+ } + { + props.entries.length > 0 && props.currentVersion ? ( + + ) : ( + + {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.noPastNamedVersions")} + + ) + } + { + props.manageNamedVersionsSlot && +
+ {props.manageNamedVersionsSlot} +
+ } +
+ ); +} + +interface VersionListProps { + entries: NamedVersion[]; + versionState: VersionState[]; + currentVersion: NamedVersion; + selectedVersionChangesetId?: string | undefined; + onVersionClicked: (targetVersion: NamedVersion) => void; + isLoading?: boolean | undefined; +} + +/** Component that displays named versions (non current). */ +function VersionList(props: VersionListProps): ReactElement { + return ( +
+
+
+
+ {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.versions")} +
+
+ Comparison Status +
+
+
+ { + props.entries.map((entry, i) => ( + + )) + } + {props.isLoading && } +
+
+
+ ); +} + +interface VersionListEntryProps { + namedVersion: NamedVersion; + versionState: VersionState; + isSelected: boolean; + onClicked: (targetVersion: NamedVersion) => void; +} + +/** + * Named Version List Entry. Displays the job information. The job will be between + * this version and the current version. Displays the description and name of the + * version as well. + */ +function VersionListEntry(props: VersionListEntryProps): ReactElement { + const isProcessed = props.versionState.state === VersionProcessedState.Processed || + (props.versionState.jobStatus !== "Processing" && props.versionState.jobStatus !== "Queued"); + + const handleClick = async () => { + if ( + props.versionState.state !== VersionProcessedState.Processed || + props.versionState.jobStatus === "Processing" || + props.versionState.jobStatus === "Queued" + ) { + return; + } + + props.onClicked(props.namedVersion); + }; + + const versionStateMap = { + [VersionProcessedState.Verifying]: { + className: "state-unavailable", + message: "VersionCompare:versionCompare.unavailable", + }, + [VersionProcessedState.Processed]: { + className: "current-empty", + message: "", + }, + [VersionProcessedState.Processing]: { + className: "state-processing", + message: "VersionCompare:versionCompare.processed", + }, + [VersionProcessedState.Unavailable]: { + className: "state-unavailable", + message: "VersionCompare:versionCompare.unavailable", + }, + }; + + const { className, message } = versionStateMap[ + props.versionState.state ?? VersionProcessedState.Unavailable + ]; + + return ( +
+
+ { /* no-op: avoid complaints for missing onChange */ }} + /> +
+ + { + props.versionState.state === VersionProcessedState.Verifying + ? ( + <> + + {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.verifying")} + + + + ) : ( + +
+
{message}
+
+
+ ) + } +
+ ); +} + +interface VersionNameAndDescriptionProps { + version: NamedVersion; + isProcessed: boolean; +} + +function VersionNameAndDescription(props: VersionNameAndDescriptionProps): ReactElement { + return ( +
+
+ {props.version.displayName} +
+
+ { + props.version.description || + IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.noDescription") + } +
+
+ ); +} + +interface DateAndCurrentProps { + createdDate?: string; + children: ReactNode; + jobStatus?: JobStatus; + jobProgress?: JobProgress; +} + +function DateCurrentAndJobInfo(props: DateAndCurrentProps): ReactElement { + const colorMap = { + red: "#efa9a9", + green: "#c3e1af", + teal: "#b7e0f2", + }; + + const jobStatusMap = { + "Available": { + backgroundColor: colorMap.green, + text: "VersionCompare:versionCompare.available", + }, + "Queued": { + backgroundColor: colorMap.teal, + text: "VersionCompare:versionCompare.queued", + }, + "Processing": { + backgroundColor: colorMap.teal, + text: "VersionCompare:versionCompare.processing", + }, + "Not Processed": { + backgroundColor: "", + text: "VersionCompare:versionCompare.notProcessed", + }, + "Error": { + backgroundColor: colorMap.red, + text: "VersionCompare:versionCompare.error", + }, + "Unknown": { + backgroundColor: "", + text: "VersionCompare:versionCompare.notProcessed", + }, + }; + + const { backgroundColor, text } = jobStatusMap[props.jobStatus ?? "Unknown"]; + const progress = props.jobProgress && Math.floor( + 100 * props.jobProgress.currentProgress / props.jobProgress.maxProgress, + ); + return ( +
+ {props.children} + { + props.jobStatus && props.jobStatus !== "Unknown" && + + {IModelApp.localization.getLocalizedString(text)} + + } + { + props.jobProgress && props.jobProgress.maxProgress > 0 && + + {`${IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.progress")}: ${progress}`}% + + } +
+ ); +} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/styles/ComparisonJobWidget.scss b/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareSelectDialogV2.scss similarity index 100% rename from packages/changed-elements-react/src/widgets/comparisonJobWidget/components/styles/ComparisonJobWidget.scss rename to packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareSelectDialogV2.scss diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareSelectDialogV2.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareSelectDialogV2.tsx new file mode 100644 index 00000000..de224aa2 --- /dev/null +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/VersionCompareSelectDialogV2.tsx @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +import { IModelApp, type IModelConnection } from "@itwin/core-frontend"; +import { Button, Modal, ModalButtonBar, ModalContent } from "@itwin/itwinui-react"; +import { useState, type ReactNode } from "react"; + +import { VersionCompareUtils, VersionCompareVerboseMessages } from "../../api/VerboseMessages"; +import type { NamedVersion } from "../../clients/iModelsClient"; +import { useNamedVersionLoader } from "./useNamedVersionLoader.js"; +import { VersionCompareSelectComponent } from "./VersionCompareSelectComponent"; + +import "./VersionCompareSelectDialogV2.scss"; + +/** Options for VersionCompareSelectDialogV2. */ +interface VersionCompareSelectDialogV2Props { + /** IModel Connection that is being visualized. */ + iModelConnection: IModelConnection; + + /** onClose triggered when user clicks start comparison or closes dialog. */ + onClose: (() => void) | undefined; + + "data-testid"?: string; + + /** Optional prop for a user supplied component to handle managing named versions. */ + manageNamedVersionsSlot?: ReactNode | undefined; +} + +/** + * VersionCompareSelectDialogV2 use comparison jobs for processing. Requires context of: + * + * ... + * + * ------------------------------------------------------------------------------------------------ + * Should be used with provider. Example: + * + * { + * (isOpenCondition) && + * + * } + * + * + * Provider should be supplied with new dialog based on condition in order to keep + * track of toast and polling information. + * + * @throws Exception if context does not include iModelsClient and comparisonJobClient. +*/ +export function VersionCompareSelectDialogV2(props: VersionCompareSelectDialogV2Props) { + const { isLoading, result, prepareComparison } = useNamedVersionLoader(props.iModelConnection); + + const [targetVersion, setTargetVersion] = useState(); + const [currentVersion, setCurrentVersion] = useState(); + + const _handleOk = async (): Promise => { + if (!result || !targetVersion || !currentVersion) { + return; + } + + props.onClose?.(); + VersionCompareUtils.outputVerbose(VersionCompareVerboseMessages.selectDialogClosed); + await prepareComparison(targetVersion, currentVersion); + }; + + const _handleCancel = (): void => { + props.onClose?.(); + VersionCompareUtils.outputVerbose(VersionCompareVerboseMessages.selectDialogClosed); + }; + + const _onVersionSelected = (currentVersion: NamedVersion, targetVersion: NamedVersion) => { + setTargetVersion(targetVersion); + setCurrentVersion(currentVersion); + VersionCompareUtils.outputVerbose(VersionCompareVerboseMessages.selectDialogOpened); + }; + + 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 deleted file mode 100644 index 0dc5ef5a..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareV2WidgetUtils.ts +++ /dev/null @@ -1,143 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { IModelConnection } from "@itwin/core-frontend"; -import { ComparisonJobCompleted, ComparisonJobStarted, IComparisonJobClient } from "../../../clients/IComparisonJobClient"; -import { IModelsClient, NamedVersion } from "../../../clients/iModelsClient"; -import { VersionCompare } from "../../../api/VersionCompare"; -import { toastComparisonVisualizationStarting } from "./versionCompareToasts"; -import { Logger } from "@itwin/core-bentley"; -import { JobAndNamedVersions, JobStatusAndJobProgress } from "../models/ComparisonJobModels"; -import { VersionState } from "../models/VersionState"; -import { ComparisonJobUpdateType } from "../components/VersionCompareDialogProvider"; - -export type ManagerStartComparisonV2Args = { - comparisonJob: ComparisonJobCompleted; - comparisonJobClient: IComparisonJobClient; - iModelConnection: IModelConnection; - targetVersion: NamedVersion; - currentVersion: NamedVersion; - getToastsEnabled?: () => boolean; - runOnJobUpdate?: (comparisonEventType: ComparisonJobUpdateType, jobAndNamedVersions?: JobAndNamedVersions) => Promise; - iModelsClient: IModelsClient; -}; - -export const runManagerStartComparisonV2 = async (args: ManagerStartComparisonV2Args) => { - if (VersionCompare.manager?.isComparing) { - await VersionCompare.manager?.stopComparison(); - } - if (args.getToastsEnabled?.()) { - toastComparisonVisualizationStarting(); - } - - const jobAndNamedVersion: JobAndNamedVersions = { - comparisonJob: args.comparisonJob, - targetNamedVersion: args.targetVersion, - currentNamedVersion: args.currentVersion, - }; - if (args.runOnJobUpdate) { - void args.runOnJobUpdate("ComparisonVisualizationStarting", jobAndNamedVersion); - } - const changedElements = await args.comparisonJobClient.getComparisonJobResult(args.comparisonJob); - VersionCompare.manager?.startComparisonV2( - args.iModelConnection, - args.currentVersion, - await updateTargetVersion(args.iModelConnection, args.targetVersion, args.iModelsClient), - [changedElements.changedElements]).catch((e) => { - Logger.logError(VersionCompare.logCategory, "Could not start version comparison: " + e); - }); -}; - -const updateTargetVersion = async (iModelConnection: IModelConnection, targetVersion: NamedVersion, iModelsClient: IModelsClient) => { - // we need to update the changesetId and index of the target version. - // earlier we updated all named versions to have an offset of 1, so we undo this offset to get the proper results from any VersionCompare.manager?.startComparisonV2 calls - // on this target version - // the change elements API requires an offset, but the IModels API does not. - const iModelId = iModelConnection?.iModelId as string; - const updatedTargetVersion = { ...targetVersion }; - updatedTargetVersion.changesetIndex = targetVersion.changesetIndex - 1; - const changeSets = await iModelsClient.getChangesets({ iModelId }).then((changesets) => changesets.slice().reverse()); - const actualChangeSet = changeSets.find((changeset) => updatedTargetVersion.changesetIndex === changeset.index); - if (actualChangeSet) { - updatedTargetVersion.changesetId = actualChangeSet.id; - } - return updatedTargetVersion; -}; - -export type GetJobStatusAndJobProgress = { - comparisonJobClient: IComparisonJobClient; - entry: VersionState; - iTwinId: string; - iModelId: string; - currentChangesetId: string; -}; - -export const getJobStatusAndJobProgress = async (args: GetJobStatusAndJobProgress): Promise => { - try { - const res = await args.comparisonJobClient.getComparisonJob({ - iTwinId: args.iTwinId, - iModelId: args.iModelId, - jobId: `${args.entry.version.changesetId}-${args.currentChangesetId}`, - }); - if (res) { - switch (res.comparisonJob.status) { - case "Completed": { - return { - jobStatus: "Available", - jobProgress: { - currentProgress: 0, - maxProgress: 0, - }, - }; - } - case "Queued": { - return { - jobStatus: "Queued", - jobProgress: { - currentProgress: 0, - maxProgress: 0, - }, - }; - } - case "Started": { - const progressingJob = res as ComparisonJobStarted; - return { - jobStatus: "Processing", - jobProgress: { - currentProgress: progressingJob.comparisonJob.currentProgress, - maxProgress: progressingJob.comparisonJob.maxProgress, - }, - }; - } - case "Failed": - return { - jobStatus: "Error", - jobProgress: { - currentProgress: 0, - maxProgress: 0, - }, - }; - } - } - return { - jobStatus: "Not Processed", - jobProgress: { - currentProgress: 0, - maxProgress: 0, - }, - }; - } catch { - return { - jobStatus: "Not Processed", - jobProgress: { - currentProgress: 0, - maxProgress: 0, - }, - }; - } -}; - -export const createJobId = (startNamedVersion: NamedVersion, endNamedVersion: NamedVersion) => { - return `${startNamedVersion.changesetId}-${endNamedVersion.changesetId}`; -}; diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareDialogProvider.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareDialogProvider.tsx deleted file mode 100644 index 04cc7a16..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareDialogProvider.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import React from "react"; -import { JobAndNamedVersions } from "../models/ComparisonJobModels"; - -/** Comparison Job Update Type -* - "JobComplete" = job is completed -* - "JobError" = job error -* - "JobProcessing" = job is started -* - "ComparisonVisualizationStarting" = version compare visualization is starting -*/ -export type ComparisonJobUpdateType = "JobComplete" | "JobError" | "JobProcessing" | "ComparisonVisualizationStarting"; - -export type V2Context = { - getDialogOpen: () => boolean; - openDialog: () => void; - closedDialog: () => void; - addRunningJob: (jobId: string, comparisonJob: JobAndNamedVersions) => void; - removeRunningJob: (jobId: string) => void; - getRunningJobs: () => JobAndNamedVersions[]; - getPendingJobs: () => JobAndNamedVersions[]; - addPendingJob: (jobId: string, comparisonJob: JobAndNamedVersions) => void; - removePendingJob: (jobId: string) => void; - getToastsEnabled: () => boolean; - runOnJobUpdate: (comparisonJobUpdateType: ComparisonJobUpdateType, jobAndNamedVersions?: JobAndNamedVersions) => Promise; -}; - -export const V2DialogContext = React.createContext({} as V2Context); - -export type V2DialogProviderProps = { - children: React.ReactNode; - // Optional. When enabled will toast messages regarding job status. If not defined will default to false and will not show toasts. - enableComparisonJobUpdateToasts?: boolean; - /** On Job Update - * Optional. a call back function for handling job updates. - * @param comparisonJobUpdateType param for the type of update: - * - "JobComplete" = invoked when job is completed - * - "JobError" = invoked on job error - * - "JobProcessing" = invoked on job is started - * - "ComparisonVisualizationStarting" = invoked on when version compare visualization is starting - * @param jobAndNamedVersion param contain job and named version info to be passed to call back -*/ - onJobUpdate?: (comparisonJobUpdateType: ComparisonJobUpdateType, jobAndNamedVersions?: JobAndNamedVersions) => Promise; -}; - -/** V2DialogProvider use comparison jobs for processing. - * Used for tracking if the dialog is open or closed. - * This is useful for managing toast messages associated with dialog. - * Also caches comparison jobs that are pending creation or are currently running. To help populate new modal ref. - * Example: - * - *{(isOpenCondition) && - * } - * -*/ -export function VersionCompareSelectProviderV2({ children, enableComparisonJobUpdateToasts, onJobUpdate }: V2DialogProviderProps) { - const dialogRunningJobs = React.useRef>(new Map()); - const dialogPendingJobs = React.useRef>(new Map()); - const addRunningJob = (jobId: string, jobAndNamedVersions: JobAndNamedVersions) => { - dialogRunningJobs.current.set(jobId, { - comparisonJob: jobAndNamedVersions.comparisonJob, - targetNamedVersion: jobAndNamedVersions.targetNamedVersion, - currentNamedVersion: jobAndNamedVersions.currentNamedVersion, - }); - }; - const removeRunningJob = (jobId: string) => { - dialogRunningJobs.current.delete(jobId); - }; - const getRunningJobs = () => { - return Array.from(dialogRunningJobs.current.values()); - }; - const addPendingJob = (jobId: string, jobAndNamedVersions: JobAndNamedVersions) => { - dialogPendingJobs.current.set(jobId, { - comparisonJob: jobAndNamedVersions.comparisonJob, - targetNamedVersion: jobAndNamedVersions.targetNamedVersion, - currentNamedVersion: jobAndNamedVersions.currentNamedVersion, - }); - }; - const removePendingJob = (jobId: string) => { - dialogPendingJobs.current.delete(jobId); - }; - const getPendingJobs = () => { - return Array.from(dialogPendingJobs.current.values()); - }; - const dialogOpenRef = React.useRef(false); - const openDialog = () => { - dialogOpenRef.current = true; - }; - const closedDialog = () => { - dialogOpenRef.current = false; - }; - const getDialogOpen = () => { - return dialogOpenRef.current; - }; - const getToastsEnabled = () => { - return enableComparisonJobUpdateToasts ?? false; - }; - const runOnJobUpdate = async (comparisonEventType: ComparisonJobUpdateType, jobAndNamedVersions?: JobAndNamedVersions) => { - if (onJobUpdate) { - void onJobUpdate(comparisonEventType, jobAndNamedVersions); - } - }; - return ( - - {children} - - ); -} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareManageNamedVersions.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareManageNamedVersions.tsx deleted file mode 100644 index dbbf1a56..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareManageNamedVersions.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { ReactNode } from "react"; -import "./styles/ComparisonJobWidget.scss"; - -interface ManageNamedVersionsProps { - children: ReactNode; -} - -/** - * Provides a div that should be populated by child component. - */ -export function ManageNamedVersions(props: ManageNamedVersionsProps) { - return ( -
- {props.children} -
); -} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectComponent.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectComponent.tsx deleted file mode 100644 index f854a2fd..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectComponent.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { IModelConnection } from "@itwin/core-frontend"; -import { ReactNode, useState } from "react"; -import { ProgressRadial } from "@itwin/itwinui-react"; -import { VersionCompareSelectorInner } from "./VersionCompareSelectorInner"; -import { CurrentNamedVersionAndNamedVersions } from "../models/NamedVersions"; -import { NamedVersion } from "../../../clients/iModelsClient"; -import { ChangesetChunk } from "../../../api/ChangedElementsApiClient"; -import "./styles/ComparisonJobWidget.scss"; - -/** Options for VersionCompareSelectComponent. */ -export interface VersionCompareSelectorProps { - /** IModel Connection that is being visualized. */ - iModelConnection: IModelConnection; - - /** Optional handler for when a version is selected. */ - onVersionSelected: (currentVersion: NamedVersion, targetVersion: NamedVersion, chunks?: ChangesetChunk[]) => void; - - /** Whether to show a title for the component or not. */ - wantTitle?: boolean; - - /** Named Versions to be displayed */ - namedVersions: CurrentNamedVersionAndNamedVersions | undefined; - - /** Optional prop for a user supplied component to handle managing named versions.*/ - manageNamedVersionsSlot?: ReactNode | undefined; - - /** If true display loading spinner to indicate we are receiving more named versions*/ - isLoading: boolean; -} - -/** - * Component that lets the user select which named version to compare to. - */ -export function VersionCompareSelectComponent(props: VersionCompareSelectorProps) { - const [targetVersion, setTargetVersion] = useState(); - - const handleVersionClicked = (targetVersion: NamedVersion) => { - setTargetVersion(targetVersion); - if (props.namedVersions && props.namedVersions.currentVersion) { - props.onVersionSelected?.( - props.namedVersions.currentVersion.version, - targetVersion, - ); - } - }; - - return props.namedVersions ? :
- -
; -} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx deleted file mode 100644 index 2e2016af..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx +++ /dev/null @@ -1,510 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { Modal, ModalContent, ModalButtonBar, Button } from "@itwin/itwinui-react"; -import { ReactNode, useEffect, useState } from "react"; -import { IModelApp, IModelConnection } from "@itwin/core-frontend"; -import React from "react"; -import { VersionCompareSelectComponent } from "./VersionCompareSelectComponent"; -import { NamedVersionLoaderState, useNamedVersionLoader } from "../hooks/useNamedVersionLoader"; -import { IComparisonJobClient, ComparisonJob, ComparisonJobCompleted } from "../../../clients/IComparisonJobClient"; -import { useVersionCompare } from "../../../VersionCompareContext"; -import { VersionCompareUtils, VersionCompareVerboseMessages } from "../../../api/VerboseMessages"; -import { IModelsClient, NamedVersion } from "../../../clients/iModelsClient"; -import { VersionCompare } from "../../../api/VersionCompare"; -import "./styles/ComparisonJobWidget.scss"; -import { arrayToMap, tryXTimes } from "../../../utils/utils"; -import { VersionState } from "../models/VersionState"; -import { JobAndNamedVersions, JobStatusAndJobProgress } from "../models/ComparisonJobModels"; -import { VersionProcessedState } from "../models/VersionProcessedState"; -import { toastComparisonJobComplete, toastComparisonJobError, toastComparisonJobProcessing } from "../common/versionCompareToasts"; -import { createJobId, getJobStatusAndJobProgress, runManagerStartComparisonV2 } from "../common/versionCompareV2WidgetUtils"; -import { ComparisonJobUpdateType, V2DialogContext } from "./VersionCompareDialogProvider"; - -/** Options for VersionCompareSelectDialogV2. */ -export interface VersionCompareSelectDialogV2Props { - /** IModel Connection that is being visualized. */ - iModelConnection: IModelConnection; - /** onClose triggered when user clicks start comparison or closes dialog.*/ - onClose: (() => void) | undefined; - "data-testid"?: string; - /** Optional prop for a user supplied component to handle managing named versions.*/ - manageNamedVersionsSlot?: ReactNode | undefined; -} - -/** VersionCompareSelectDialogV2 use comparison jobs for processing. - * Requires context of: - * - * ... - * - *------------------------------------------------------------------------------------------------ - * Should be used with provider. Example: - * - *{(isOpenCondition) && - * } - * - * provider should be supplied with new dialog based on condition in order to keep track of toast and polling information. - * @throws Exception if context does not include iModelsClient and comparisonJobClient. -*/ -export function VersionCompareSelectDialogV2(props: VersionCompareSelectDialogV2Props) { - const { comparisonJobClient, iModelsClient } = useVersionCompare(); - if (!comparisonJobClient) { - throw new Error("V2 Client Is Not Initialized In Given Context."); - } - if (!iModelsClient) { - throw new Error("V1 Client Is Not Initialized In Given Context."); - } - const { openDialog, closedDialog, getDialogOpen, addRunningJob, removeRunningJob, getRunningJobs - , getPendingJobs, removePendingJob, addPendingJob, getToastsEnabled, runOnJobUpdate } = React.useContext(V2DialogContext); - const [targetVersion, setTargetVersion] = useState(undefined); - const [currentVersion, setCurrentVersion] = useState(undefined); - const [result, setResult] = useState(); - const { isLoading } = useNamedVersionLoader(props.iModelConnection, iModelsClient, comparisonJobClient, setResult, getPendingJobs); - useEffect(() => { - let isDisposed = false; - const getIsDisposed = () => { - return isDisposed; - }; - openDialog(); - if (result && result?.namedVersions.entries) { - void pollForInProgressJobs({ - iTwinId: props.iModelConnection.iTwinId as string, - iModelId: props.iModelConnection.iModelId as string, - namedVersionLoaderState: result, - comparisonJobClient: comparisonJobClient, - iModelConnection: props.iModelConnection, - setResult: setResult, - removeRunningJob: removeRunningJob, - getRunningJobs: getRunningJobs, - getDialogOpen: getDialogOpen, - getIsDisposed, - getToastsEnabled, - runOnJobUpdate, - iModelsClient, - }); - } - return () => { - isDisposed = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoading]); - const _handleOk = async (): Promise => { - if (comparisonJobClient && result?.namedVersions && targetVersion && currentVersion) { - const getIsDisposed = () => true; - props.onClose?.(); - closedDialog(); - VersionCompareUtils.outputVerbose(VersionCompareVerboseMessages.selectDialogClosed); - const startResult = await createOrRunManagerStartComparisonV2({ - targetVersion: targetVersion, - comparisonJobClient: comparisonJobClient, - iModelConnection: props.iModelConnection, - currentVersion: currentVersion, - addPendingJob, - removePendingJob, - getDialogOpen, - getToastsEnabled, - runOnJobUpdate, - iModelsClient, - }); - if (startResult?.comparisonJob) { - addRunningJob(createJobId(targetVersion, currentVersion), { - comparisonJob: startResult.comparisonJob, - targetNamedVersion: { - id: targetVersion.id, - displayName: targetVersion.displayName, - changesetId: targetVersion.changesetId, - changesetIndex: targetVersion.changesetIndex, - description: targetVersion.description, - createdDateTime: targetVersion.createdDateTime, - }, - currentNamedVersion: { - id: currentVersion.id, - displayName: currentVersion.displayName, - changesetId: currentVersion.changesetId, - changesetIndex: currentVersion.changesetIndex, - description: currentVersion.description, - createdDateTime: currentVersion.createdDateTime, - }, - }); - void pollForInProgressJobs({ - iTwinId: props.iModelConnection.iTwinId as string, - iModelId: props.iModelConnection.iModelId as string, - namedVersionLoaderState: result, - comparisonJobClient: comparisonJobClient, - iModelConnection: props.iModelConnection, - setResult: setResult, - removeRunningJob: removeRunningJob, - getRunningJobs: getRunningJobs, - getDialogOpen: getDialogOpen, - getIsDisposed, - getToastsEnabled, - runOnJobUpdate, - iModelsClient, - }); - } - } - }; - - const _handleCancel = (): void => { - props.onClose?.(); - closedDialog(); - VersionCompareUtils.outputVerbose(VersionCompareVerboseMessages.selectDialogClosed); - }; - - const _onVersionSelected = (currentVersion: NamedVersion, targetVersion: NamedVersion) => { - setTargetVersion(targetVersion); - setCurrentVersion(currentVersion); - VersionCompareUtils.outputVerbose(VersionCompareVerboseMessages.selectDialogOpened); - }; - return ( - - - - - - - - - - ); -} - -//todo refactor all types in this file they are not dry. We want "type" space to be as clean as "value" space. -type RunStartComparisonV2Args = { - targetVersion: NamedVersion; - comparisonJobClient: IComparisonJobClient; - iModelConnection: IModelConnection; - currentVersion: NamedVersion; - removePendingJob: (jobId: string) => void; - addPendingJob: (jobId: string, comparisonJob: JobAndNamedVersions) => void; - getDialogOpen: () => boolean; - getToastsEnabled: () => boolean; - runOnJobUpdate: (comparisonJobUpdateType: ComparisonJobUpdateType, jobAndNamedVersions?: JobAndNamedVersions) => Promise; - iModelsClient: IModelsClient; -}; - -type PostOrRunComparisonJobResult = { - startedComparison: boolean; - comparisonJob?: ComparisonJob; -}; - -const createOrRunManagerStartComparisonV2 = async (args: RunStartComparisonV2Args): Promise => { - const jobId = createJobId(args.targetVersion, args.currentVersion); - try { - args.addPendingJob(jobId, { - targetNamedVersion: args.targetVersion, - currentNamedVersion: args.currentVersion, - }); - let comparisonJob = await tryXTimes(async () => { - const job = (await postOrGetComparisonJob({ - changedElementsClient: args.comparisonJobClient, - iTwinId: args.iModelConnection?.iTwinId as string, - iModelId: args.iModelConnection?.iModelId as string, - startChangesetId: args.targetVersion.changesetId as string, - endChangesetId: args.currentVersion.changesetId as string, - })); - args.removePendingJob(jobId); - return job; - }, 3); - if (comparisonJob.comparisonJob.status === "Failed") { - comparisonJob = await handleJobError({ ...args, comparisonJob: comparisonJob }); - } - if (comparisonJob.comparisonJob.status === "Completed") { - void runManagerStartComparisonV2({ - comparisonJob: comparisonJob as ComparisonJobCompleted, - comparisonJobClient: args.comparisonJobClient, - iModelConnection: args.iModelConnection, - targetVersion: args.targetVersion, - currentVersion: args.currentVersion, - getToastsEnabled: args.getToastsEnabled, - runOnJobUpdate: args.runOnJobUpdate, - iModelsClient: args.iModelsClient, - }); - return { startedComparison: true }; - } - if (args.getToastsEnabled() && !args.getDialogOpen()) { - toastComparisonJobProcessing(args.currentVersion, args.targetVersion); - } - const jobAndNamedVersion: JobAndNamedVersions = { - comparisonJob: comparisonJob, - targetNamedVersion: args.targetVersion, - currentNamedVersion: args.currentVersion, - }; - void args.runOnJobUpdate("JobProcessing", jobAndNamedVersion); - - return { startedComparison: false, comparisonJob: comparisonJob }; - } catch (error) { - args.removePendingJob(jobId); - if (args.getToastsEnabled()) { - toastComparisonJobError(args.currentVersion, args.targetVersion); - } - const jobAndNamedVersion: JobAndNamedVersions = { - comparisonJob: undefined, - targetNamedVersion: args.targetVersion, - currentNamedVersion: args.currentVersion, - }; - void args.runOnJobUpdate("JobError", jobAndNamedVersion); - return undefined; - } -}; - -type handleJobErrorArgs = Omit & { - comparisonJob: ComparisonJob; -}; - -const handleJobError: (args: handleJobErrorArgs) => Promise = async (args) => { - args.addPendingJob(args.comparisonJob.comparisonJob.jobId, { - targetNamedVersion: args.targetVersion, - currentNamedVersion: args.currentVersion, - }); - await args.comparisonJobClient.deleteComparisonJob({ - iTwinId: args.comparisonJob.comparisonJob.iTwinId, - iModelId: args.comparisonJob.comparisonJob.iModelId, - jobId: args.comparisonJob.comparisonJob.jobId, - }); - return tryXTimes(async () => { - const job = (await postOrGetComparisonJob({ - changedElementsClient: args.comparisonJobClient, - iTwinId: args.iModelConnection?.iTwinId as string, - iModelId: args.iModelConnection?.iModelId as string, - startChangesetId: args.targetVersion.changesetId as string, - endChangesetId: args.currentVersion.changesetId as string, - })); - args.removePendingJob(job.comparisonJob.jobId); - return job; - }, 3); -}; - -type PollForInProgressJobsArgs = { - iTwinId: string; - iModelId: string; - namedVersionLoaderState?: NamedVersionLoaderState; - comparisonJobClient: IComparisonJobClient; - iModelConnection: IModelConnection; - setResult: (result: NamedVersionLoaderState) => void; - removeRunningJob: (jobId: string) => void; - getRunningJobs: () => JobAndNamedVersions[]; - getDialogOpen: () => boolean; - getIsDisposed: () => boolean; - targetVersion?: NamedVersion; - getToastsEnabled: () => boolean; - runOnJobUpdate: (comparisonJobUpdateType: ComparisonJobUpdateType, jobAndNamedVersions?: JobAndNamedVersions) => Promise; - iModelsClient: IModelsClient; -}; - -export const pollForInProgressJobs: (args: PollForInProgressJobsArgs) => Promise = async (args: PollForInProgressJobsArgs) => { - void pollUntilCurrentRunningJobsCompleteAndToast(args); - if (args.namedVersionLoaderState && args.namedVersionLoaderState.namedVersions.entries.length > 0 && args.getDialogOpen() && !args.getIsDisposed()) - void pollUpdateCurrentEntriesForModal(args); -}; - -const pollUntilCurrentRunningJobsCompleteAndToast = async (args: PollForInProgressJobsArgs) => { - let isConnectionClosed = false; - args.iModelConnection.onClose.addListener(() => { isConnectionClosed = true; }); - const loopDelayInMilliseconds = 5000; - while (shouldProcessRunningJobs({ getRunningJobs: args.getRunningJobs, isConnectionClosed })) { - await new Promise((resolve) => setTimeout(resolve, loopDelayInMilliseconds)); - for (const runningJob of args.getRunningJobs()) { - try { - const completedJob = await args.comparisonJobClient.getComparisonJob({ - iTwinId: args.iTwinId, - iModelId: args.iModelId, - jobId: runningJob?.comparisonJob?.comparisonJob.jobId as string, - }); - if (completedJob.comparisonJob.status === "Failed") { - args.removeRunningJob(runningJob?.comparisonJob?.comparisonJob.jobId as string); - continue; - } - notifyComparisonCompletion({ - isConnectionClosed: isConnectionClosed, - getRunningJobs: args.getRunningJobs, - getDialogOpen: args.getDialogOpen, - runningJob: runningJob, - currentJobRsp: completedJob, - removeRunningJob: args.removeRunningJob, - comparisonJobClient: args.comparisonJobClient, - iModelConnection: args.iModelConnection, - getToastsEnabled: args.getToastsEnabled, - runOnJobUpdate: args.runOnJobUpdate, - iModelsClient: args.iModelsClient, - }); - } catch (error) { - args.removeRunningJob(runningJob?.comparisonJob?.comparisonJob.jobId as string); - throw error; - } - } - } -}; - -type ShouldProcessRunningJobArgs = { - isConnectionClosed: boolean; - getRunningJobs: () => JobAndNamedVersions[]; -}; - -const shouldProcessRunningJobs = (args: ShouldProcessRunningJobArgs) => { - return args.getRunningJobs().length > 0 && !args.isConnectionClosed; -}; - -type ConditionallyToastCompletionArgs = { - isConnectionClosed: boolean; - getRunningJobs: () => JobAndNamedVersions[]; - getDialogOpen: () => boolean; - runningJob: JobAndNamedVersions; - currentJobRsp: ComparisonJob; - removeRunningJob: (jobId: string) => void; - comparisonJobClient: IComparisonJobClient; - iModelConnection: IModelConnection; - getToastsEnabled: () => boolean; - runOnJobUpdate: (comparisonJobUpdateType: ComparisonJobUpdateType, jobAndNamedVersions?: JobAndNamedVersions) => Promise; - iModelsClient: IModelsClient; -}; -const notifyComparisonCompletion = (args: ConditionallyToastCompletionArgs) => { - if (args.currentJobRsp.comparisonJob.status === "Completed") { - args.removeRunningJob(args.runningJob?.comparisonJob?.comparisonJob.jobId as string); - if (!VersionCompare.manager?.isComparing && !args.getDialogOpen()) { - if (args.getToastsEnabled()) { - toastComparisonJobComplete({ - comparisonJob: args.currentJobRsp as ComparisonJobCompleted, - comparisonJobClient: args.comparisonJobClient, - iModelConnection: args.iModelConnection, - targetVersion: args.runningJob.targetNamedVersion, - currentVersion: args.runningJob.currentNamedVersion, - getToastsEnabled: args.getToastsEnabled, - runOnJobUpdate: args.runOnJobUpdate, - iModelsClient: args.iModelsClient, - }); - } - const jobAndNamedVersion: JobAndNamedVersions = { - comparisonJob: args.currentJobRsp, - targetNamedVersion: args.runningJob.targetNamedVersion, - currentNamedVersion: args.runningJob.currentNamedVersion, - }; - void args.runOnJobUpdate("JobComplete", jobAndNamedVersion); - } - } -}; - -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; }); - if (areJobsInProgress(entries, args.getRunningJobs)) { - const idEntryMap = arrayToMap(entries, (entry: VersionState) => { return entry.version.id; }); - let updatingEntries = getUpdatingEntries(entries, currentVersionId, currentRunningJobsMap); - const loopDelayInMilliseconds = 5000; - while (isDialogOpenAndNotDisposed(args.getDialogOpen, args.getIsDisposed)) { - for (let entry of updatingEntries) { - await new Promise((resolve) => setTimeout(resolve, loopDelayInMilliseconds)); - const jobStatusAndJobProgress: JobStatusAndJobProgress = await getJobStatusAndJobProgress({ - comparisonJobClient: args.comparisonJobClient, - entry: entry, - iTwinId: args.iTwinId, - iModelId: args.iModelId, - currentChangesetId: currentVersionId, - }); - entry = { - version: entry.version, - state: VersionProcessedState.Processed, - jobStatus: jobStatusAndJobProgress.jobStatus, - jobProgress: jobStatusAndJobProgress.jobProgress, - }; - idEntryMap.set(entry.version.id, entry); - if (jobStatusAndJobProgress.jobStatus === "Available") { - args.removeRunningJob(`${entry.version.changesetId}-${currentVersionId}`); - } - } - entries = [...idEntryMap.values()]; - updatingEntries = getUpdatingEntries(entries, currentVersionId, currentRunningJobsMap); - - if (isDialogOpenAndNotDisposed(args.getDialogOpen, args.getIsDisposed)) { - const updatedState = { - namedVersions: { currentVersion: args.namedVersionLoaderState!.namedVersions.currentVersion, entries: entries }, - }; - args.setResult(updatedState); - } - } - } -}; - -const isDialogOpenAndNotDisposed = (getDialogOpen: () => boolean, getIsDisposed: () => boolean) => { - return getDialogOpen() && !getIsDisposed(); -}; - -const areJobsInProgress = (entries: VersionState[], getRunningJobs: () => JobAndNamedVersions[]) => { - return entries.find(entry => entry.jobStatus === "Processing" || entry.jobStatus === "Queued") !== undefined || getRunningJobs().length > 0; -}; - -const getUpdatingEntries = (entries: VersionState[], currentVersionId: string, currentRunningJobsMap: Map) => { - return entries.filter((entry) => { - if (entry.jobStatus === "Processing" || entry.jobStatus === "Queued") - return true; - const jobId = `${entry.version.changesetId}-${currentVersionId}`; - return currentRunningJobsMap.has(jobId); - }); -}; - -type PostOrGetComparisonJobParams = { - changedElementsClient: IComparisonJobClient; - iTwinId: string; - iModelId: string; - startChangesetId: string; - endChangesetId: string; -}; - -/** -* post or gets comparison job. -* @returns ComparisonJob -* @throws on a non 2XX response. -*/ -async function postOrGetComparisonJob(args: PostOrGetComparisonJobParams): Promise { - let result: ComparisonJob; - try { - result = await args.changedElementsClient.getComparisonJob({ - iTwinId: args.iTwinId, - iModelId: args.iModelId, - jobId: `${args.startChangesetId}-${args.endChangesetId}`, - headers: { - "Content-Type": "application/json", - }, - }); - } catch (error: unknown) { - if (error && typeof error === "object" && "code" in error && error.code === "ComparisonNotFound") { - result = await args.changedElementsClient.postComparisonJob({ - iTwinId: args.iTwinId, - iModelId: args.iModelId, - startChangesetId: args.startChangesetId, - endChangesetId: args.endChangesetId, - headers: { "Content-Type": "application/json" }, - }); - return result; - } - throw error; - } - return result; -} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectorInner.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectorInner.tsx deleted file mode 100644 index ce716568..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectorInner.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { IModelApp } from "@itwin/core-frontend"; -import { Text } from "@itwin/itwinui-react"; -import { ReactNode } from "react"; -import { VersionList } from "./VersionList"; -import { CurrentVersionEntry } from "./VersionEntries"; -import { VersionState } from "../models/VersionState"; -import { NamedVersion } from "../../../clients/iModelsClient"; -import "./styles/ComparisonJobWidget.scss"; -import { ManageNamedVersions } from "./VersionCompareManageNamedVersions"; - -interface VersionCompareSelectorInnerProps { - entries: VersionState[]; - currentVersion: VersionState | undefined; - selectedVersionChangesetId: string | undefined; - onVersionClicked: (targetVersion: NamedVersion) => void; - wantTitle: boolean | undefined; - - /** Optional prop for a user supplied component to handle managing named versions.*/ - manageNamedVersionsSlot?: ReactNode | undefined; - - /** If true display loading spinner to indicate we are receiving more named versions*/ - isLoading: boolean; -} - -/** - * Component that houses named version list. - * Also displays the current versions information. - */ -export function VersionCompareSelectorInner(props: VersionCompareSelectorInnerProps) { - return ( -
- { - props.currentVersion && -
-
- {`${IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.compare")}:`} -
-
- -
-
- } - { - props.wantTitle && -
- {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.versionCompare")} -
- } - {
- {`${IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.withPrevious")}:`} -
} - { - props.entries.length > 0 && props.currentVersion ? ( - - ) : ( - - {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.noPastNamedVersions")} - - ) - } - { - props.manageNamedVersionsSlot && - - {props.manageNamedVersionsSlot} - - } -
- ); -} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionEntries.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionEntries.tsx deleted file mode 100644 index 78803fe4..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionEntries.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { ReactElement, ReactNode } from "react"; -import { ProgressLinear, Radio, Badge, Text } from "@itwin/itwinui-react"; -import { IModelApp } from "@itwin/core-frontend"; -import { JobStatus, JobProgress } from "../models/ComparisonJobModels"; -import { VersionProcessedState } from "../models/VersionProcessedState"; -import { NamedVersion } from "../../../clients/iModelsClient"; -import { VersionState } from "../models/VersionState"; -import "./styles/ComparisonJobWidget.scss"; - -interface CurrentVersionEntryProps { - versionState: VersionState; -} - -/** - * Component for current version. - * Displays the current version's name date description. - */ -export function CurrentVersionEntry(props: CurrentVersionEntryProps): ReactElement { - const isProcessed = props.versionState.state === VersionProcessedState.Processed; - return ( -
- - -
- {props.versionState.version.createdDateTime ? new Date(props.versionState.version.createdDateTime).toDateString() : ""} -
-
- {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.current")} -
-
-
- ); -} - -interface DateAndCurrentProps { - createdDate?: string; - children: ReactNode; - jobStatus?: JobStatus; - jobProgress?: JobProgress; -} - -function DateCurrentAndJobInfo(props: DateAndCurrentProps): ReactElement { - const jobBadgeBackground = getJobBackgroundColor(props.jobStatus ?? "Unknown"); - - return ( -
- {props.children} - {props.jobStatus === undefined || props.jobStatus === "Unknown" ? <> : - {`${getLocalizedJobStatusText(props.jobStatus)}`}} - {props.jobProgress === undefined || props.jobProgress.maxProgress === 0 ? <> - : - {`${IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.progress")}: ${Math.floor((props.jobProgress.currentProgress / props.jobProgress.maxProgress) * 100)}%`} - } -
- ); -} - -const getJobBackgroundColor = (jobStatus: JobStatus): string => { - const green = "#c3e1af"; - const teal = "#b7e0f2"; - const red = "#efa9a9"; - switch (jobStatus) { - case "Available": - return green; - case "Queued": - return teal; - case "Processing": - return teal; - case "Not Processed": - return ""; - case "Error": - return red; - default: - return ""; - } -}; - -const getLocalizedJobStatusText = (jobStatus: JobStatus): string => { - switch (jobStatus) { - case "Available": - return IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.available"); - case "Queued": - return IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.queued"); - case "Processing": - return IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.processing"); - case "Not Processed": - return IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.notProcessed"); - case "Error": - return IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error"); - default: - return IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.notProcessed"); - } -}; - -interface VersionNameAndDescriptionProps { - version: NamedVersion; - isProcessed: boolean; -} - -function VersionNameAndDescription(props: VersionNameAndDescriptionProps): ReactElement { - return ( -
-
- {props.version.displayName} -
-
- {props.version.description === "" - ? IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.noDescription") - : props.version.description} -
-
- ); -} - -interface VersionListEntryProps { - versionState: VersionState; - isSelected: boolean; - onClicked: (targetVersion: NamedVersion) => void; -} - -/** - * Named Version List Entry. - * Displays the job information. The job will be between this version and the current version. - * Displays the description and name of the version as well. - */ -export function VersionListEntry(props: VersionListEntryProps): ReactElement { - const handleClick = async () => { - if (props.versionState.state !== VersionProcessedState.Processed || props.versionState.jobStatus === "Processing" || props.versionState.jobStatus === "Queued") { - return; - } - - props.onClicked(props.versionState.version); - }; - - const getStateDivClassname = () => { - switch (props.versionState.state) { - case VersionProcessedState.Processed: - return "current-empty"; - case VersionProcessedState.Processing: - return "state-processing"; - case VersionProcessedState.Unavailable: - default: - return "state-unavailable"; - } - }; - const getStateDivMessage = () => { - switch (props.versionState.state) { - case VersionProcessedState.Processed: - return ""; - case VersionProcessedState.Processing: { - return IModelApp.localization.getLocalizedString( - "VersionCompare:versionCompare.processed", - ); - } - case VersionProcessedState.Unavailable: - default: - return IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.unavailable"); - } - }; - const getAvailableDate = () => { - return ( - -
-
{getStateDivMessage()}
-
-
- ); - }; - - const isProcessed = props.versionState.state === VersionProcessedState.Processed || (props.versionState.jobStatus !== "Processing" && props.versionState.jobStatus !== "Queued"); - return ( -
-
- { /* no-op: avoid complaints for missing onChange */ }} - /> -
- - { - props.versionState.state === VersionProcessedState.Verifying - ? <> - - {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.verifying")} - - - - : getAvailableDate() - } -
- ); -} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionList.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionList.tsx deleted file mode 100644 index 2b54d501..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionList.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { IModelApp } from "@itwin/core-frontend"; -import { ReactElement } from "react"; -import { VersionListEntry } from "./VersionEntries"; -import { VersionState } from "../models/VersionState"; -import { NamedVersion } from "../../../clients/iModelsClient"; -import "./styles/ComparisonJobWidget.scss"; -import { LoadingSpinner } from "@itwin/core-react"; - -interface VersionListProps { - entries: VersionState[]; - currentVersion: VersionState; - selectedVersionChangesetId: string | undefined; - onVersionClicked: (targetVersion: NamedVersion) => void; - - /** If true display loading spinner to indicate we are receiving more named versions*/ - isLoading: boolean; -} - -/** - * Component that displays named versions (non current). - */ -export function VersionList(props: VersionListProps): ReactElement { - return ( -
-
-
-
- {IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.versions")} -
-
- {"Comparison Status"} -
-
-
- {props.entries.map((versionState) => { - const isSelected = props.selectedVersionChangesetId !== undefined && - versionState.version.changesetId === props.selectedVersionChangesetId; - return ( - - ); - })} - {props.isLoading && } -
-
-
- ); -} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/hooks/useNamedVersionLoader.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/hooks/useNamedVersionLoader.tsx deleted file mode 100644 index 9bd0ca11..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/hooks/useNamedVersionLoader.tsx +++ /dev/null @@ -1,302 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { IModelApp, IModelConnection } from "@itwin/core-frontend"; -import { useEffect, useState } from "react"; -import { JobStatus, JobProgress, JobStatusAndJobProgress, JobAndNamedVersions } from "../models/ComparisonJobModels"; -import { VersionProcessedState } from "../models/VersionProcessedState"; -import { CurrentNamedVersionAndNamedVersions } from "../models/NamedVersions"; -import { IComparisonJobClient } from "../../../clients/IComparisonJobClient"; -import { IModelsClient, NamedVersion } from "../../../clients/iModelsClient"; -import { createJobId, getJobStatusAndJobProgress } from "../common/versionCompareV2WidgetUtils"; -import { arrayToMap } from "../../../utils/utils"; - -/** - * Result type for versionLoader. - */ -export type NamedVersionLoaderState = { - /** Named versions to display in the list. */ - namedVersions: CurrentNamedVersionAndNamedVersions; -}; - -interface UseNamedVersionLoaderResult { - isLoading: boolean; -} - -/** - * Loads name versions and their job status compared to current version iModel is targeting. - * Returns a result object with current version and namedVersion with there job status sorted from newest to oldest. - * This is run during the initial load of the widget. - */ -export const useNamedVersionLoader = ( - iModelConnection: IModelConnection, - iModelsClient: IModelsClient, - comparisonJobClient: IComparisonJobClient, - setNamedVersionResult: (state: NamedVersionLoaderState) => void, - getPendingJobs: () => JobAndNamedVersions[], - pageSize: number = 20, -): UseNamedVersionLoaderResult => { - const [isLoading, setIsLoading] = useState(true); - useEffect( - () => { - const setResultNoNamedVersions = () => { - setNamedVersionResult({ - namedVersions: { entries: [], currentVersion: undefined }, - }); - }; - const iTwinId = iModelConnection?.iTwinId; - const iModelId = iModelConnection?.iModelId; - const currentChangeSetId = iModelConnection?.changeset.id; - const currentChangeSetIndex = iModelConnection?.changeset.index; - let disposed = false; - if (!iTwinId || !iModelId || !currentChangeSetId) { - setResultNoNamedVersions(); - return; - } - - void (async () => { - let currentNamedVersion: NamedVersion | undefined; - let currentState: NamedVersionLoaderState | undefined = undefined; - let currentPage = 0; - while (!disposed) { - try { - // Get a page of named versions - const namedVersions = await iModelsClient.getNamedVersions( - { - iModelId, - top: pageSize, - skip: currentPage * pageSize, - orderby: "changesetIndex", - ascendingOrDescending: "desc", - }); - if (!currentNamedVersion) - currentNamedVersion = await getOrCreateCurrentNamedVersion(namedVersions, currentChangeSetId, iModelsClient, iModelId, currentChangeSetIndex); - - if (namedVersions.length === 0) { - setIsLoading(false); - break; // No more named versions to process - } - // Process the named versions - const processedNamedVersionsState = await processNamedVersions({ - currentNamedVersion: currentNamedVersion, - namedVersions: namedVersions, - setResultNoNamedVersions: setResultNoNamedVersions, - iModelsClient: iModelsClient, - iModelId: iModelId, - updatePaging: setIsLoading, - iTwinId: iTwinId, - iModelConnection: iModelConnection, - comparisonJobClient: comparisonJobClient, - getPendingJobs: getPendingJobs, - }); - - if (processedNamedVersionsState) { - if (currentState) { - const updatedState: NamedVersionLoaderState = { - namedVersions: { - entries: currentState.namedVersions.entries.concat(processedNamedVersionsState.namedVersions.entries), - currentVersion: processedNamedVersionsState?.namedVersions.currentVersion, - }, - }; - currentState = updatedState; - } else { - currentState = processedNamedVersionsState; - } - setNamedVersionResult(currentState); - } - currentPage++; - } catch (error) { - setIsLoading(false); - break; - } - } - })(); - return () => { - disposed = true; - }; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [comparisonJobClient, iModelConnection, iModelsClient], - ); - return { isLoading }; -}; - -type ProcessNamedVersionsArgs = { - namedVersions: NamedVersion[]; - currentNamedVersion: NamedVersion; - setResultNoNamedVersions: () => void; - iModelsClient: IModelsClient; - iModelId: string; - updatePaging: (isPaging: boolean) => void; - iTwinId: string; - iModelConnection: IModelConnection; - comparisonJobClient: IComparisonJobClient; - getPendingJobs: () => JobAndNamedVersions[]; -}; - - -const processNamedVersions = async (args: ProcessNamedVersionsArgs) => { - const { - namedVersions, - setResultNoNamedVersions, - iModelsClient, - iModelId, - updatePaging, - comparisonJobClient, - iTwinId, - iModelConnection, - getPendingJobs, - currentNamedVersion, - } = args; - const sortedAndOffsetNamedVersions = await sortAndSetIndexOfNamedVersions(namedVersions, currentNamedVersion, setResultNoNamedVersions, iModelsClient, iModelId); - if (!sortedAndOffsetNamedVersions || sortedAndOffsetNamedVersions.length === 0) { - setResultNoNamedVersions(); - updatePaging(false); - return; - } - const initialComparisonJobStatus: JobStatus = "Unknown"; - const initialJobProgress: JobProgress = { - currentProgress: 0, - maxProgress: 0, - }; - const namedVersionState: NamedVersionLoaderState = { - namedVersions: { - entries: sortedAndOffsetNamedVersions.map((namedVersion) => ({ - version: namedVersion, - state: VersionProcessedState.Verifying, - jobStatus: initialComparisonJobStatus, - jobProgress: initialJobProgress, - })), - currentVersion: { - version: currentNamedVersion, - state: VersionProcessedState.Processed, - jobStatus: initialComparisonJobStatus, - jobProgress: initialJobProgress, - }, - }, - }; - return getComparisonJobInfoForNamedVersions({ - iModelConnection: iModelConnection, - iTwinId: iTwinId, - iModelId: iModelId, - namedVersionLoaderState: namedVersionState, - comparisonJobClient: comparisonJobClient, - getPendingJobs, - }); -}; - -// create faked named version if current version is not a named version -const getOrCreateCurrentNamedVersion = async (namedVersions: NamedVersion[], currentChangeSetId: string, iModelsClient: IModelsClient, iModelId?: string, currentChangeSetIndex?: number): Promise => { - const currentFromNamedVersion = getCurrentFromNamedVersions(namedVersions, currentChangeSetId, currentChangeSetIndex); - if (currentFromNamedVersion) - return currentFromNamedVersion; - const currentFromChangeSet = await getCurrentFromChangeSet(currentChangeSetId, iModelsClient, iModelId); - if (currentFromChangeSet) - return currentFromChangeSet; - return { - id: currentChangeSetId, - displayName: IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.currentChangeset"), - changesetId: currentChangeSetId, - changesetIndex: currentChangeSetIndex ?? 0, - description: "", - createdDateTime: "", - }; -}; - -const getCurrentFromNamedVersions = (namedVersions: NamedVersion[], currentChangeSetId: string, currentChangeSetIndex?: number) => { - const currentNamedVersion = namedVersions.find(version => (version.changesetId === currentChangeSetId || version.changesetIndex === currentChangeSetIndex)); - if (currentNamedVersion) { - return currentNamedVersion; - } - return undefined; -}; - -const getCurrentFromChangeSet = async (currentChangeSetId: string, iModelsClient: IModelsClient, iModelId?: string): Promise => { - if (!iModelId) - return undefined; - const currentChangeSet = await iModelsClient.getChangeset({ iModelId: iModelId, changesetId: currentChangeSetId }); - if (currentChangeSet) { - return { - id: currentChangeSet.id, - displayName: currentChangeSet.displayName, - changesetId: currentChangeSet.id, - changesetIndex: currentChangeSet.index, - description: currentChangeSet.description, - createdDateTime: currentChangeSet.pushDateTime, - }; - } - return undefined; -}; - -const sortAndSetIndexOfNamedVersions = async (namedVersions: NamedVersion[], currentNamedVersion: NamedVersion, onError: () => void, iModelsClient: IModelsClient, iModelId: string) => { - //if current index is 0 then no need to filter. All change sets are older than current. - const namedVersionsOlderThanCurrentVersion = currentNamedVersion.changesetIndex !== 0 ? namedVersions.filter(version => version.changesetIndex <= currentNamedVersion.changesetIndex) : - namedVersions; - if (namedVersionsOlderThanCurrentVersion.length === 0) { - onError(); - return; - } - const reversedNamedVersions = namedVersionsOlderThanCurrentVersion; - if (reversedNamedVersions[0].changesetIndex === currentNamedVersion.changesetIndex) { - reversedNamedVersions.shift(); //remove current named version - } - // we must offset the named versions , because that changeset is "already applied" to the named version, see this: - // https://developer.bentley.com/tutorials/changed-elements-api/#221-using-the-api-to-get-changed-elements - // this assuming latest is current - const promises = reversedNamedVersions.map(async (nameVersion) => { - nameVersion.changesetIndex = nameVersion.changesetIndex + 1; - const changesetId = nameVersion.changesetIndex.toString(); - const changeSet = await iModelsClient.getChangeset({ iModelId: iModelId, changesetId: changesetId }); - nameVersion.changesetId = changeSet?.id ?? nameVersion.changesetId; - return nameVersion; - }); - - return Promise.all(promises); -}; - -type ProcessChangesetsArgs = { - iTwinId: string; - iModelId: string; - namedVersionLoaderState: NamedVersionLoaderState; - iModelConnection: IModelConnection; - comparisonJobClient: IComparisonJobClient; - getPendingJobs: () => JobAndNamedVersions[]; -}; - -const getComparisonJobInfoForNamedVersions = async (args: ProcessChangesetsArgs) => { - const pendingJobsMap = arrayToMap(args.getPendingJobs(), (job: JobAndNamedVersions) => { return createJobId(job.targetNamedVersion, job.currentNamedVersion); }); - const currentVersionId = args.namedVersionLoaderState.namedVersions.currentVersion?.version.changesetId ?? - args.iModelConnection?.changeset.id; - const newEntries = await Promise.all(args.namedVersionLoaderState.namedVersions.entries.map(async (entry) => { - const jobStatusAndJobProgress: JobStatusAndJobProgress = await getJobStatusAndJobProgress({ - comparisonJobClient: args.comparisonJobClient, - entry: entry, - iTwinId: args.iTwinId, - iModelId: args.iModelId, - currentChangesetId: currentVersionId, - }); - if (pendingJobsMap.has(`${entry.version.changesetId}-${currentVersionId}`)) { - const jobStatus: JobStatus = "Processing"; - return { - version: entry.version, - state: VersionProcessedState.Processed, - jobStatus: jobStatus, - jobProgress: { - currentProgress: 0, - maxProgress: 1, - }, - }; - } - return { - version: entry.version, - state: VersionProcessedState.Processed, - jobStatus: jobStatusAndJobProgress.jobStatus, - jobProgress: jobStatusAndJobProgress.jobProgress, - }; - })); - const updatedState = { - namedVersions: { currentVersion: args.namedVersionLoaderState.namedVersions.currentVersion, entries: newEntries }, - }; - return updatedState; -}; diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/NamedVersions.ts b/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/NamedVersions.ts deleted file mode 100644 index 0d4051a1..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/NamedVersions.ts +++ /dev/null @@ -1,13 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { VersionState } from "./VersionState"; - -/** - * Holds the version state of named versions and the current version. -*/ -export interface CurrentNamedVersionAndNamedVersions { - entries: VersionState[]; - currentVersion: VersionState | undefined; -} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/VersionProcessedState.ts b/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/VersionProcessedState.ts deleted file mode 100644 index 96622c88..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/VersionProcessedState.ts +++ /dev/null @@ -1,15 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ - -/** - * Give state of named version. - * Helps with finding data seeding and processing state of named version. -*/ -export enum VersionProcessedState { - Verifying, - Processed, - Processing, - Unavailable, -} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/VersionState.ts b/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/VersionState.ts deleted file mode 100644 index fff94d29..00000000 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/models/VersionState.ts +++ /dev/null @@ -1,19 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { NamedVersion } from "../../../clients/iModelsClient"; -import { VersionProcessedState } from "./VersionProcessedState"; -import { JobProgress, JobStatus } from "./ComparisonJobModels"; - -/** - * Holds the state of of the version and its subsequent meta data. -*/ -export type VersionState = { - version: NamedVersion; - state: VersionProcessedState; - // nullable because we don't run jobs in V1. For v2 use only. - jobStatus?: JobStatus; - // nullable because we don't run jobs in V1. For v2 use only. - jobProgress?: JobProgress; -}; diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/useNamedVersionLoader.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/useNamedVersionLoader.tsx new file mode 100644 index 00000000..aee2320d --- /dev/null +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/useNamedVersionLoader.tsx @@ -0,0 +1,808 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +import { IModelApp, type IModelConnection } from "@itwin/core-frontend"; +import { + useContext, useEffect, useRef, useState, type RefObject, type SetStateAction +} from "react"; + +import { VersionCompare } from "../../api/VersionCompare.js"; +import type { + ComparisonJob, ComparisonJobCompleted, IComparisonJobClient +} from "../../clients/IComparisonJobClient"; +import type { IModelsClient, NamedVersion } from "../../clients/iModelsClient"; +import { tryXTimes } from "../../utils/utils.js"; +import { useVersionCompare } from "../../VersionCompareContext.js"; +import { + VersionProcessedState, type JobAndNamedVersions, type VersionState +} from "./NamedVersions.js"; +import { V2DialogContext, type ComparisonJobUpdateType } from "./VersionCompareDialogProvider.js"; +import { + toastComparisonJobComplete, toastComparisonJobError, toastComparisonJobProcessing +} from "./versionCompareToasts.js"; +import { + createJobId, getJobStatusAndJobProgress, runManagerStartComparisonV2 +} from "./versionCompareV2WidgetUtils"; + +interface UseNamedVersionLoaderResult { + isLoading: boolean; + isError: boolean; + result: { + entries: NamedVersion[]; + currentVersion: NamedVersion | undefined; + versionState: VersionState[]; + } | undefined; + prepareComparison: ( + targetVersion: NamedVersion, + currentVersion: NamedVersion, + ) => Promise; +} + +/** + * Loads name versions and their job status compared to current version iModel is + * targeting. Returns a result object with current version and namedVersion with + * there job status sorted from newest to oldest. This is run during the initial + * load of the widget. + */ +export function useNamedVersionLoader( + iModelConnection: IModelConnection, + pageSize: number = 20, +): UseNamedVersionLoaderResult { + const { comparisonJobClient, iModelsClient } = useVersionCompare(); + if (!comparisonJobClient) { + throw new Error("V2 Client is not initialized in given context."); + } + + const { + addRunningJob, removeRunningJob, getRunningJobs, getPendingJobs, removePendingJob, + addPendingJob, getToastsEnabled, runOnJobUpdate, + } = useContext(V2DialogContext); + + const [state, setState] = useState>({ + isLoading: true, + isError: false, + result: undefined, + }); + + useInitialLoading({ + setState, + iModelConnection, + iModelsClient, + pageSize, + comparisonJobClient, + getPendingJobs, + }); + + const isDisposedRef = useRef(false); + useEffect(() => () => { isDisposedRef.current = true; }, []); + + useEffect( + () => { + let isDisposed = false; + const getIsDisposed = () => isDisposed; + if (state.result?.entries) { + pollForInProgressJobs({ + iTwinId: iModelConnection.iTwinId as string, + iModelId: iModelConnection.iModelId as string, + namedVersionLoaderState: state.result, + comparisonJobClient, + iModelConnection, + setResult: (versionState) => { + setState((prev) => ({ + ...prev, + result: prev.result && { + ...prev.result, + versionState, + }, + })); + }, + removeRunningJob, + getRunningJobs, + getIsDisposed, + getToastsEnabled, + runOnJobUpdate, + iModelsClient, + }); + } + + return () => { + isDisposed = true; + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [state.isLoading], + ); + + return { + ...state, + prepareComparison: async (targetVersion, currentVersion) => { + const startResult = await createOrRunManagerStartComparisonV2({ + targetVersion, + comparisonJobClient, + iModelConnection, + currentVersion, + isDisposedRef, + addPendingJob, + removePendingJob, + getToastsEnabled, + runOnJobUpdate, + iModelsClient, + }); + + if (startResult?.comparisonJob) { + addRunningJob( + createJobId(targetVersion, currentVersion), + { + comparisonJob: startResult.comparisonJob, + targetNamedVersion: targetVersion, + currentNamedVersion: currentVersion, + }, + ); + pollForInProgressJobs({ + iTwinId: iModelConnection.iTwinId as string, + iModelId: iModelConnection.iModelId as string, + namedVersionLoaderState: state.result, + comparisonJobClient, + iModelConnection, + setResult: (versionState) => setState((prev) => ({ + ...prev, + result: { + ...prev.result ?? { currentVersion: undefined, entries: [] }, + versionState, + }, + })), + removeRunningJob, + getRunningJobs, + getIsDisposed: () => true, + getToastsEnabled, + runOnJobUpdate, + iModelsClient, + }); + } + }, + }; +} + +interface UseInitialLoadingArgs { + setState: ( + action: SetStateAction>, + ) => void; + iModelConnection: IModelConnection; + iModelsClient: IModelsClient; + pageSize: number; + comparisonJobClient: IComparisonJobClient; + getPendingJobs: () => JobAndNamedVersions[]; +} + +function useInitialLoading(args: UseInitialLoadingArgs): void { + useEffect( + () => { + const setResultNoNamedVersions = () => { + args.setState({ + isLoading: false, + isError: false, + result: { + entries: [], + currentVersion: undefined, + versionState: [], + }, + }); + }; + const { iTwinId, iModelId, changeset } = args.iModelConnection; + if (!iTwinId || !iModelId) { + setResultNoNamedVersions(); + return; + } + + let disposed = false; + void (async () => { + let currentNamedVersion: NamedVersion | undefined; + let currentState: EntriesAndCurrent | undefined = undefined; + let currentPage = 0; + while (!disposed) { + try { + // Get a page of named versions + const namedVersions = await args.iModelsClient.getNamedVersions({ + iModelId, + top: args.pageSize, + skip: currentPage * args.pageSize, + orderby: "changesetIndex", + ascendingOrDescending: "desc", + }); + if (!currentNamedVersion) { + currentNamedVersion = await getOrCreateCurrentNamedVersion( + namedVersions, + changeset.id, + args.iModelsClient, + iModelId, + changeset.index, + ); + } + + if (namedVersions.length === 0) { + // No more named versions to process + args.setState((prev) => ({ ...prev, isLoading: false })); + break; + } + + // Process the named versions + const processedNamedVersionsState = await processNamedVersions({ + currentNamedVersion, + namedVersions, + setResultNoNamedVersions, + iModelsClient: args.iModelsClient, + iModelId, + updatePaging: (isPaging) => args.setState((prev) => ({ ...prev, isLoading: isPaging })), + }); + + if (processedNamedVersionsState) { + const comparisonState = await queryComparisonState({ + namedVersions: processedNamedVersionsState.entries, + iTwinId, + iModelId, + currentVersion: processedNamedVersionsState.currentVersion, + iModelConnection: args.iModelConnection, + comparisonJobClient: args.comparisonJobClient, + getPendingJobs: args.getPendingJobs, + }); + + if (currentState) { + currentState = { + entries: currentState.entries.concat(processedNamedVersionsState.entries), + currentVersion: processedNamedVersionsState?.currentVersion, + }; + } else { + currentState = processedNamedVersionsState; + } + + const localCurrentState = currentState; + args.setState((prev) => ({ + ...prev, + result: { + currentVersion: localCurrentState.currentVersion, + entries: localCurrentState.entries, + versionState: comparisonState, + }, + })); + } + + currentPage++; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + args.setState((prev) => ({ ...prev, isError: true, isLoading: false })); + break; + } + } + })(); + return () => { + disposed = true; + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [args.comparisonJobClient, args.iModelConnection, args.iModelsClient], + ); +} + +interface EntriesAndCurrent { + entries: NamedVersion[]; + currentVersion: NamedVersion | undefined; +} + +interface ProcessNamedVersionsArgs { + namedVersions: NamedVersion[]; + currentNamedVersion: NamedVersion; + setResultNoNamedVersions: () => void; + iModelsClient: IModelsClient; + iModelId: string; + updatePaging: (isPaging: boolean) => void; +} + +async function processNamedVersions( + args: ProcessNamedVersionsArgs, +): Promise { + const sortedAndOffsetNamedVersions = await sortAndSetIndexOfNamedVersions( + args.namedVersions, + args.currentNamedVersion, + args.setResultNoNamedVersions, + args.iModelsClient, + args.iModelId, + ); + if (!sortedAndOffsetNamedVersions || sortedAndOffsetNamedVersions.length === 0) { + args.setResultNoNamedVersions(); + args.updatePaging(false); + return undefined; + } + + return { + entries: sortedAndOffsetNamedVersions, + currentVersion: args.currentNamedVersion, + }; +} + +// create faked named version if current version is not a named version +async function getOrCreateCurrentNamedVersion( + namedVersions: NamedVersion[], + currentChangeSetId: string, + iModelsClient: IModelsClient, + iModelId?: string, + currentChangeSetIndex?: number, +): Promise { + const currentFromNamedVersion = namedVersions.find((version) => ( + version.changesetId === currentChangeSetId || + version.changesetIndex === currentChangeSetIndex + )); + if (currentFromNamedVersion) { + return currentFromNamedVersion; + } + + const currentFromChangeSet = await getCurrentFromChangeSet( + currentChangeSetId, + iModelsClient, + iModelId, + ); + if (currentFromChangeSet) { + return currentFromChangeSet; + } + + return { + id: currentChangeSetId, + displayName: IModelApp.localization.getLocalizedString( + "VersionCompare:versionCompare.currentChangeset", + ), + changesetId: currentChangeSetId, + changesetIndex: currentChangeSetIndex ?? 0, + description: "", + createdDateTime: "", + }; +} + +async function getCurrentFromChangeSet( + currentChangeSetId: string, + iModelsClient: IModelsClient, + iModelId?: string, +): Promise { + if (!iModelId) { + return undefined; + } + + const currentChangeSet = await iModelsClient.getChangeset({ + iModelId, + changesetId: currentChangeSetId, + }); + if (!currentChangeSet) { + return undefined; + } + + return { + id: currentChangeSet.id, + displayName: currentChangeSet.displayName, + changesetId: currentChangeSet.id, + changesetIndex: currentChangeSet.index, + description: currentChangeSet.description, + createdDateTime: currentChangeSet.pushDateTime, + }; +} + +async function sortAndSetIndexOfNamedVersions( + namedVersions: NamedVersion[], + currentNamedVersion: NamedVersion, + onError: () => void, + iModelsClient: IModelsClient, + iModelId: string, +): Promise { + // If current index is 0, then no need to filter. All change sets are older than current. + const namedVersionsOlderThanCurrentVersion = currentNamedVersion.changesetIndex === 0 + ? namedVersions + : namedVersions.filter( + (version) => version.changesetIndex <= currentNamedVersion.changesetIndex, + ); + if (namedVersionsOlderThanCurrentVersion.length === 0) { + onError(); + return undefined; + } + + const reversedNamedVersions = namedVersionsOlderThanCurrentVersion; + if (reversedNamedVersions[0].changesetIndex === currentNamedVersion.changesetIndex) { + reversedNamedVersions.shift(); // Remove current named version + } + + // We must offset the named versions, because that changeset is "already applied" + // to the named version, see this: + // https://developer.bentley.com/tutorials/changed-elements-api/#221-using-the-api-to-get-changed-elements + // This assuming latest is current + return Promise.all( + reversedNamedVersions.map(async (nameVersion) => { + nameVersion.changesetIndex = nameVersion.changesetIndex + 1; + const changesetId = nameVersion.changesetIndex.toString(); + const changeSet = await iModelsClient.getChangeset({ iModelId, changesetId }); + nameVersion.changesetId = changeSet?.id ?? nameVersion.changesetId; + return nameVersion; + }), + ); +} + +interface QueryComparisonState { + namedVersions: NamedVersion[]; + iTwinId: string; + iModelId: string; + currentVersion: NamedVersion | undefined; + iModelConnection: IModelConnection; + comparisonJobClient: IComparisonJobClient; + getPendingJobs: () => JobAndNamedVersions[]; +} + +async function queryComparisonState( + args: QueryComparisonState, +): Promise { + const pendingJobsMap = new Set( + args.getPendingJobs().map( + (job) => `${createJobId(job.targetNamedVersion, job.currentNamedVersion)}`, + ), + ); + const currentVersionId = args.currentVersion?.changesetId ?? args.iModelConnection?.changeset.id; + return Promise.all( + args.namedVersions.map(async (entry) => { + const jobId = `${entry.changesetId}-${currentVersionId}`; + if (pendingJobsMap.has(jobId)) { + return { + jobId, + state: VersionProcessedState.Processed, + jobStatus: "Processing", + jobProgress: { currentProgress: 0, maxProgress: 1 }, + }; + } + + const { jobStatus, jobProgress } = await getJobStatusAndJobProgress({ + comparisonJobClient: args.comparisonJobClient, + iTwinId: args.iTwinId, + iModelId: args.iModelId, + jobId, + }); + return { jobId, state: VersionProcessedState.Processed, jobStatus, jobProgress }; + }), + ); +} + +interface PollForInProgressJobsArgs { + iTwinId: string; + iModelId: string; + namedVersionLoaderState?: NamedVersionLoaderState; + comparisonJobClient: IComparisonJobClient; + iModelConnection: IModelConnection; + setResult: (result: VersionState[]) => void; + removeRunningJob: (jobId: string) => void; + getRunningJobs: () => JobAndNamedVersions[]; + getIsDisposed: () => boolean; + getToastsEnabled: () => boolean; + runOnJobUpdate: ( + comparisonJobUpdateType: ComparisonJobUpdateType, + jobAndNamedVersions?: JobAndNamedVersions, + ) => Promise; + iModelsClient: IModelsClient; +} + +interface NamedVersionLoaderState { + entries: NamedVersion[]; + currentVersion: NamedVersion | undefined; + versionState: VersionState[]; +} + +export function pollForInProgressJobs(args: PollForInProgressJobsArgs): void { + void pollUntilCurrentRunningJobsCompleteAndToast(args); + if ( + args.namedVersionLoaderState && + args.namedVersionLoaderState.entries.length > 0 + && !args.getIsDisposed() + ) { + void pollUpdateCurrentEntriesForModal(args); + } +} + +async function pollUntilCurrentRunningJobsCompleteAndToast( + args: PollForInProgressJobsArgs, +): Promise { + let isConnectionClosed = false; + args.iModelConnection.onClose.addListener(() => { isConnectionClosed = true; }); + while (args.getRunningJobs().length > 0 && !isConnectionClosed) { + const loopDelayInMilliseconds = 5000; + await new Promise((resolve) => setTimeout(resolve, loopDelayInMilliseconds)); + for (const runningJob of args.getRunningJobs()) { + try { + const completedJob = await args.comparisonJobClient.getComparisonJob({ + iTwinId: args.iTwinId, + iModelId: args.iModelId, + jobId: runningJob?.comparisonJob?.comparisonJob.jobId as string, + }); + if (completedJob.comparisonJob.status === "Failed") { + args.removeRunningJob(runningJob?.comparisonJob?.comparisonJob.jobId as string); + continue; + } + + notifyComparisonCompletion({ + isConnectionClosed: isConnectionClosed, + getRunningJobs: args.getRunningJobs, + getIsDisposed: args.getIsDisposed, + runningJob, + currentJobRsp: completedJob, + removeRunningJob: args.removeRunningJob, + comparisonJobClient: args.comparisonJobClient, + iModelConnection: args.iModelConnection, + getToastsEnabled: args.getToastsEnabled, + runOnJobUpdate: args.runOnJobUpdate, + iModelsClient: args.iModelsClient, + }); + } catch (error) { + args.removeRunningJob(runningJob?.comparisonJob?.comparisonJob.jobId as string); + throw error; + } + } + } +} + +interface ConditionallyToastCompletionArgs { + isConnectionClosed: boolean; + getRunningJobs: () => JobAndNamedVersions[]; + getIsDisposed: () => boolean; + runningJob: JobAndNamedVersions; + currentJobRsp: ComparisonJob; + removeRunningJob: (jobId: string) => void; + comparisonJobClient: IComparisonJobClient; + iModelConnection: IModelConnection; + getToastsEnabled: () => boolean; + runOnJobUpdate: ( + comparisonJobUpdateType: ComparisonJobUpdateType, + jobAndNamedVersions?: JobAndNamedVersions, + ) => Promise; + iModelsClient: IModelsClient; +} + +function notifyComparisonCompletion(args: ConditionallyToastCompletionArgs): void { + if (args.currentJobRsp.comparisonJob.status === "Completed") { + args.removeRunningJob(args.runningJob?.comparisonJob?.comparisonJob.jobId as string); + if (!VersionCompare.manager?.isComparing && args.getIsDisposed()) { + if (args.getToastsEnabled()) { + toastComparisonJobComplete({ + comparisonJob: args.currentJobRsp as ComparisonJobCompleted, + comparisonJobClient: args.comparisonJobClient, + iModelConnection: args.iModelConnection, + targetVersion: args.runningJob.targetNamedVersion, + currentVersion: args.runningJob.currentNamedVersion, + getToastsEnabled: args.getToastsEnabled, + runOnJobUpdate: args.runOnJobUpdate, + iModelsClient: args.iModelsClient, + }); + } + + const jobAndNamedVersion: JobAndNamedVersions = { + comparisonJob: args.currentJobRsp, + targetNamedVersion: args.runningJob.targetNamedVersion, + currentNamedVersion: args.runningJob.currentNamedVersion, + }; + void args.runOnJobUpdate("JobComplete", jobAndNamedVersion); + } + } +} + +async function pollUpdateCurrentEntriesForModal(args: PollForInProgressJobsArgs): Promise { + /** Mutable array of immutable VersionState elements. */ + const localState = args.namedVersionLoaderState!.versionState; + + const currentRunningJobs = new Set( + args.getRunningJobs().map((job) => job.comparisonJob?.comparisonJob.jobId), + ); + const currentUpdatingEntries = localState + .map((entry, entryIndex) => ({ entry, entryIndex })) + .filter(({ entry }) => ( + entry.jobStatus === "Processing" || + entry.jobStatus === "Queued" || + currentRunningJobs.has(entry.jobId) + )); + + if (currentUpdatingEntries.length === 0) { + return; + } + + const syncState = () => { + currentUpdatingEntries.forEach(({ entry, entryIndex }) => localState[entryIndex] = entry); + args.setResult(localState.slice()); + }; + + while (!args.getIsDisposed()) { + syncState(); + + const loopDelayInMilliseconds = 5000; + for (let i = 0; i < currentUpdatingEntries.length; i++) { + const { entry } = currentUpdatingEntries[i]; + if (entry.jobStatus !== "Processing" && entry.jobStatus !== "Queued") { + continue; + } + + await new Promise((resolve) => setTimeout(resolve, loopDelayInMilliseconds)); + const jobStatusAndJobProgress = await getJobStatusAndJobProgress({ + comparisonJobClient: args.comparisonJobClient, + iTwinId: args.iTwinId, + iModelId: args.iModelId, + jobId: entry.jobId, + }); + currentUpdatingEntries[i].entry = { + jobId: entry.jobId, + state: VersionProcessedState.Processed, + jobStatus: jobStatusAndJobProgress.jobStatus, + jobProgress: jobStatusAndJobProgress.jobProgress, + }; + if (jobStatusAndJobProgress.jobStatus === "Available") { + args.removeRunningJob(entry.jobId); + } + } + } +} + +// TODO: refactor all types in this file they are not dry. We want "type" space +// to be as clean as "value" space. +interface RunStartComparisonV2Args { + targetVersion: NamedVersion; + comparisonJobClient: IComparisonJobClient; + iModelConnection: IModelConnection; + currentVersion: NamedVersion; + removePendingJob: (jobId: string) => void; + addPendingJob: (jobId: string, comparisonJob: JobAndNamedVersions) => void; + isDisposedRef: RefObject; + getToastsEnabled: () => boolean; + runOnJobUpdate: ( + comparisonJobUpdateType: ComparisonJobUpdateType, + jobAndNamedVersions?: JobAndNamedVersions, + ) => Promise; + iModelsClient: IModelsClient; +} + +interface PostOrRunComparisonJobResult { + startedComparison: boolean; + comparisonJob?: ComparisonJob; +} + +async function createOrRunManagerStartComparisonV2( + args: RunStartComparisonV2Args, +): Promise { + const jobId = createJobId(args.targetVersion, args.currentVersion); + try { + args.addPendingJob( + jobId, + { + targetNamedVersion: args.targetVersion, + currentNamedVersion: args.currentVersion, + }, + ); + let comparisonJob = await tryXTimes( + async () => { + const job = await postOrGetComparisonJob({ + changedElementsClient: args.comparisonJobClient, + iTwinId: args.iModelConnection?.iTwinId as string, + iModelId: args.iModelConnection?.iModelId as string, + startChangesetId: args.targetVersion.changesetId as string, + endChangesetId: args.currentVersion.changesetId as string, + }); + args.removePendingJob(jobId); + return job; + }, + 3, + ); + if (comparisonJob.comparisonJob.status === "Failed") { + comparisonJob = await handleJobError({ ...args, comparisonJob: comparisonJob }); + } + + if (comparisonJob.comparisonJob.status === "Completed") { + void runManagerStartComparisonV2({ + comparisonJob: comparisonJob as ComparisonJobCompleted, + comparisonJobClient: args.comparisonJobClient, + iModelConnection: args.iModelConnection, + targetVersion: args.targetVersion, + currentVersion: args.currentVersion, + getToastsEnabled: args.getToastsEnabled, + runOnJobUpdate: args.runOnJobUpdate, + iModelsClient: args.iModelsClient, + }); + return { startedComparison: true }; + } + + if (args.getToastsEnabled() && args.isDisposedRef.current) { + toastComparisonJobProcessing(args.currentVersion, args.targetVersion); + } + + void args.runOnJobUpdate( + "JobProcessing", + { + comparisonJob, + targetNamedVersion: args.targetVersion, + currentNamedVersion: args.currentVersion, + }, + ); + + return { startedComparison: false, comparisonJob }; + } catch (error) { + args.removePendingJob(jobId); + if (args.getToastsEnabled()) { + toastComparisonJobError(args.currentVersion, args.targetVersion); + } + + void args.runOnJobUpdate( + "JobError", + { + comparisonJob: undefined, + targetNamedVersion: args.targetVersion, + currentNamedVersion: args.currentVersion, + }, + ); + return undefined; + } +} + +interface HandleJobErrorArgs { + comparisonJob: ComparisonJob; + targetVersion: NamedVersion; + currentVersion: NamedVersion; + addPendingJob: (jobId: string, comparisonJob: JobAndNamedVersions) => void; + removePendingJob: (jobId: string) => void; + comparisonJobClient: IComparisonJobClient; + iModelConnection: IModelConnection; +} + +async function handleJobError(args: HandleJobErrorArgs): Promise { + args.addPendingJob(args.comparisonJob.comparisonJob.jobId, { + targetNamedVersion: args.targetVersion, + currentNamedVersion: args.currentVersion, + }); + await args.comparisonJobClient.deleteComparisonJob({ + iTwinId: args.comparisonJob.comparisonJob.iTwinId, + iModelId: args.comparisonJob.comparisonJob.iModelId, + jobId: args.comparisonJob.comparisonJob.jobId, + }); + return tryXTimes( + async () => { + const job = (await postOrGetComparisonJob({ + changedElementsClient: args.comparisonJobClient, + iTwinId: args.iModelConnection.iTwinId as string, + iModelId: args.iModelConnection.iModelId as string, + startChangesetId: args.targetVersion.changesetId as string, + endChangesetId: args.currentVersion.changesetId as string, + })); + args.removePendingJob(job.comparisonJob.jobId); + return job; + }, + 3, + ); +} + +interface PostOrGetComparisonJobParams { + changedElementsClient: IComparisonJobClient; + iTwinId: string; + iModelId: string; + startChangesetId: string; + endChangesetId: string; +} + +async function postOrGetComparisonJob(args: PostOrGetComparisonJobParams): Promise { + try { + return await args.changedElementsClient.getComparisonJob({ + iTwinId: args.iTwinId, + iModelId: args.iModelId, + jobId: `${args.startChangesetId}-${args.endChangesetId}`, + headers: { "Content-Type": "application/json" }, + }); + } catch (error: unknown) { + if ( + error && typeof error === "object" && "code" in error && error.code === "ComparisonNotFound" + ) { + return args.changedElementsClient.postComparisonJob({ + iTwinId: args.iTwinId, + iModelId: args.iModelId, + startChangesetId: args.startChangesetId, + endChangesetId: args.endChangesetId, + headers: { "Content-Type": "application/json" }, + }); + } + + throw error; + } +} diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareToasts.ts b/packages/changed-elements-react/src/widgets/comparisonJobWidget/versionCompareToasts.ts similarity index 90% rename from packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareToasts.ts rename to packages/changed-elements-react/src/widgets/comparisonJobWidget/versionCompareToasts.ts index 7d54cee7..bd842dfc 100644 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareToasts.ts +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/versionCompareToasts.ts @@ -2,13 +2,18 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { IModelApp, IModelConnection, NotifyMessageDetails, OutputMessagePriority, OutputMessageType } from "@itwin/core-frontend"; -import { IModelsClient, NamedVersion } from "../../../clients/iModelsClient"; +import { + IModelApp, NotifyMessageDetails, OutputMessagePriority, OutputMessageType, type IModelConnection +} from "@itwin/core-frontend"; import { toaster } from "@itwin/itwinui-react"; -import { runManagerStartComparisonV2 } from "./versionCompareV2WidgetUtils"; -import { ComparisonJobCompleted, IComparisonJobClient } from "../../../clients/IComparisonJobClient"; -import { ComparisonJobUpdateType } from "../components/VersionCompareDialogProvider"; -import { JobAndNamedVersions } from "../models/ComparisonJobModels"; + +import type { + ComparisonJobCompleted, IComparisonJobClient +} from "../../clients/IComparisonJobClient"; +import type { IModelsClient, NamedVersion } from "../../clients/iModelsClient"; +import type { JobAndNamedVersions } from "./NamedVersions.js"; +import type { ComparisonJobUpdateType } from "./VersionCompareDialogProvider"; +import { runManagerStartComparisonV2 } from "./versionCompareV2WidgetUtils.js"; /** Toast Comparison Job Processing. * Outputs toast message following the pattern: diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/versionCompareV2WidgetUtils.ts b/packages/changed-elements-react/src/widgets/comparisonJobWidget/versionCompareV2WidgetUtils.ts new file mode 100644 index 00000000..6ef2c1fa --- /dev/null +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/versionCompareV2WidgetUtils.ts @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* 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 { VersionCompare } from "../../api/VersionCompare"; +import type { + ComparisonJob, ComparisonJobCompleted, ComparisonJobStarted, IComparisonJobClient +} from "../../clients/IComparisonJobClient"; +import type { IModelsClient, NamedVersion } from "../../clients/iModelsClient"; +import type { ComparisonJobUpdateType } from "./VersionCompareDialogProvider"; +import type { JobAndNamedVersions, JobStatusAndJobProgress } from "./NamedVersions.js"; +import { toastComparisonVisualizationStarting } from "./versionCompareToasts"; + +export interface ManagerStartComparisonV2Args { + comparisonJob: ComparisonJobCompleted; + comparisonJobClient: IComparisonJobClient; + iModelConnection: IModelConnection; + targetVersion: NamedVersion; + currentVersion: NamedVersion; + getToastsEnabled?: () => boolean; + runOnJobUpdate?: ( + comparisonEventType: ComparisonJobUpdateType, + jobAndNamedVersions?: JobAndNamedVersions, + ) => void; + iModelsClient: IModelsClient; +} + +export async function runManagerStartComparisonV2( + args: ManagerStartComparisonV2Args, +): Promise { + if (VersionCompare.manager?.isComparing) { + await VersionCompare.manager?.stopComparison(); + } + + if (args.getToastsEnabled?.()) { + toastComparisonVisualizationStarting(); + } + + const jobAndNamedVersion: JobAndNamedVersions = { + comparisonJob: args.comparisonJob, + targetNamedVersion: args.targetVersion, + currentNamedVersion: args.currentVersion, + }; + args.runOnJobUpdate?.("ComparisonVisualizationStarting", jobAndNamedVersion); + + const changedElements = await args.comparisonJobClient.getComparisonJobResult(args.comparisonJob); + VersionCompare.manager?.startComparisonV2( + args.iModelConnection, + args.currentVersion, + await updateTargetVersion(args.iModelConnection, args.targetVersion, args.iModelsClient), + [changedElements.changedElements], + ).catch((e) => { + Logger.logError(VersionCompare.logCategory, "Could not start version comparison: " + e); + }); +} + +async function updateTargetVersion( + iModelConnection: IModelConnection, + targetVersion: NamedVersion, + iModelsClient: IModelsClient, +): Promise { + // We need to update the changesetId and index of the target version. Earlier + // we updated all named versions to have an offset of 1, so we undo this offset + // to get the proper results from any VersionCompare.manager?.startComparisonV2 + // calls on this target version. The change elements API requires an offset, but + // the IModels API does not. + const iModelId = iModelConnection?.iModelId as string; + const updatedTargetVersion = { ...targetVersion }; + updatedTargetVersion.changesetIndex = targetVersion.changesetIndex - 1; + const changesets = await iModelsClient.getChangesets({ iModelId }); + const actualChangeSet = changesets.slice().reverse().find( + (changeset) => updatedTargetVersion.changesetIndex === changeset.index, + ); + if (actualChangeSet) { + updatedTargetVersion.changesetId = actualChangeSet.id; + } + + return updatedTargetVersion; +} + +export interface GetJobStatusAndJobProgress { + comparisonJobClient: IComparisonJobClient; + iTwinId: string; + iModelId: string; + jobId: string; +} + +export async function getJobStatusAndJobProgress( + args: GetJobStatusAndJobProgress, +): Promise { + let res: ComparisonJob; + try { + res = await args.comparisonJobClient.getComparisonJob({ + iTwinId: args.iTwinId, + iModelId: args.iModelId, + jobId: args.jobId, + }); + } catch { + return { + jobStatus: "Not Processed", + jobProgress: { + currentProgress: 0, + maxProgress: 0, + }, + }; + } + + switch (res.comparisonJob.status) { + case "Completed": { + return { + jobStatus: "Available", + jobProgress: { + currentProgress: 0, + maxProgress: 0, + }, + }; + } + + case "Queued": { + return { + jobStatus: "Queued", + jobProgress: { + currentProgress: 0, + maxProgress: 0, + }, + }; + } + + case "Started": { + const progressingJob = res as ComparisonJobStarted; + return { + jobStatus: "Processing", + jobProgress: { + currentProgress: progressingJob.comparisonJob.currentProgress, + maxProgress: progressingJob.comparisonJob.maxProgress, + }, + }; + } + + case "Failed": + return { + jobStatus: "Error", + jobProgress: { + currentProgress: 0, + maxProgress: 0, + }, + }; + + default: + return { + jobStatus: "Not Processed", + jobProgress: { + currentProgress: 0, + maxProgress: 0, + }, + }; + } +} + +export function createJobId( + startNamedVersion: NamedVersion, + endNamedVersion: NamedVersion, +): string { + return `${startNamedVersion.changesetId}-${endNamedVersion.changesetId}`; +}