diff --git a/apps/test-viewer/src/components/FormatManager.tsx b/apps/test-viewer/src/components/FormatManager.tsx deleted file mode 100644 index 5ffd224848..0000000000 --- a/apps/test-viewer/src/components/FormatManager.tsx +++ /dev/null @@ -1,234 +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 { BeEvent } from "@itwin/core-bentley"; -import type { IModelConnection } from "@itwin/core-frontend"; -import { IModelApp } from "@itwin/core-frontend"; -import type { FormatDefinition, FormatsChangedArgs, FormatsProvider, MutableFormatsProvider } from "@itwin/core-quantity"; -import type { FormatSet } from "@itwin/ecschema-metadata"; -import { SchemaFormatsProvider, SchemaItem, SchemaItemType, SchemaKey, SchemaMatchType } from "@itwin/ecschema-metadata"; - -export class FormatManager { - protected static _instance: FormatManager; - private _formatSets: FormatSet[] = []; - private _fallbackFormatProvider?: FormatsProvider; - private _activeFormatSet?: FormatSet; - private _activeFormatSetFormatsProvider?: FormatSetFormatsProvider; - private _iModelOpened: boolean = false; - private _removeListeners: (() => void)[] = []; - - /** Event raised when the active format set changes */ - public readonly onActiveFormatSetChanged = new BeEvent<(formatSet: FormatSet | undefined) => void>(); - - public static get instance(): FormatManager | undefined { - return this._instance; - } - - public get formatSets(): FormatSet[] { - return this._formatSets; - } - - public set formatSets(formatSets: FormatSet[]) { - this._formatSets = formatSets; - } - - public get activeFormatSet(): FormatSet | undefined { - return this._activeFormatSet; - } - - public get activeFormatSetFormatsProvider(): FormatSetFormatsProvider | undefined { - return this._activeFormatSetFormatsProvider; - } - - /** Initialize with a set of format sets to use */ - public static async initialize(formatSets: FormatSet[], fallbackProvider?: FormatsProvider): Promise { - if (this._instance) throw new Error("FormatManager is already initialized."); - - this._instance = new FormatManager(formatSets, fallbackProvider); - } - - public constructor(formatSets: FormatSet[], fallbackProvider?: FormatsProvider) { - this._formatSets = formatSets; - this._fallbackFormatProvider = fallbackProvider; - } - - public [Symbol.dispose](): void { - for (const listener of this._removeListeners) { - listener(); - } - this._removeListeners = []; - } - - public setActiveFormatSet(formatSet: FormatSet): void { - const formatSetFormatsProvider = new FormatSetFormatsProvider(formatSet, this._fallbackFormatProvider); - this._activeFormatSet = formatSet; - this._activeFormatSetFormatsProvider = formatSetFormatsProvider; - - if (this._iModelOpened) { - IModelApp.formatsProvider = formatSetFormatsProvider; - } - - this.onActiveFormatSetChanged.raiseEvent(formatSet); - } - - // Typically, enables a SchemaFormatsProvider to be the fallback during runtime. - public set fallbackFormatsProvider(provider: FormatsProvider | undefined) { - this._fallbackFormatProvider = provider; - if (this._activeFormatSet) { - // If we have an active format set, we need to update the formats provider to include the new fallback. - const newFormatSetFormatsProvider = new FormatSetFormatsProvider(this._activeFormatSet, this._fallbackFormatProvider); - this._activeFormatSetFormatsProvider = newFormatSetFormatsProvider; - IModelApp.formatsProvider = newFormatSetFormatsProvider; - } - } - - public get fallbackFormatsProvider(): FormatsProvider | undefined { - return this._fallbackFormatProvider; - } - - public async onIModelClose() { - // Clean up listeners - this._removeListeners.forEach((removeListener) => removeListener()); - this._fallbackFormatProvider = undefined; - if (this._activeFormatSetFormatsProvider) { - this._activeFormatSetFormatsProvider.clearFallbackProvider(); // Works here because the fallback provider is the SchemaFormatsProvider used onIModelOpen. - } - this._iModelOpened = false; - } - - /** - * If FormatSetFormatsProvider was successfully set, renders the usage of IModelApp.quantityFormatter.activeUnitSystem pointless when formatting. - */ - public async onIModelOpen(iModel: IModelConnection): Promise { - // Set up schema-based units and formats providers - const schemaFormatsProvider = new SchemaFormatsProvider(iModel.schemaContext, IModelApp.quantityFormatter.activeUnitSystem); - this.fallbackFormatsProvider = schemaFormatsProvider; - this._removeListeners.push( - IModelApp.quantityFormatter.onActiveFormattingUnitSystemChanged.addListener((args) => { - schemaFormatsProvider.unitSystem = args.system; - }), - ); - // Query schemas for KindOfQuantity items - try { - const schemaFormatSet: FormatSet = { - name: "SchemaFormats", - label: "Example Format Set", - formats: {}, - }; - // Used until https://github.com/iTwin/bis-schemas/issues/566 is resolved - // If there are duplicate labels, use the unique fullName of the KoQ instead of it's label. - const usedLabels: Set = new Set(); - - // Try to get known schemas that typically contain KindOfQuantity items, and get all the formats from kind of quantities - const schemaNames = ["AecUnits"]; - - for (const schemaName of schemaNames) { - try { - const schema = await iModel.schemaContext.getSchema(new SchemaKey(schemaName, SchemaMatchType.Latest)); - if (schema) { - for (const schemaItem of schema.getItems()) { - if (schemaItem.schemaItemType === SchemaItemType.KindOfQuantity) { - const format = await schemaFormatsProvider.getFormat(schemaItem.fullName); - if (format) { - if (format.label) { - if (usedLabels.has(format.label)) { - (format as any).label = `${format.label} (${schemaItem.key.schemaName})`; - } - usedLabels.add(format.label); - } - schemaFormatSet.formats[schemaItem.fullName] = format; - } - } - } - } - } catch (error) { - console.warn(`Schema ${schemaName} not found or failed to load:`, error); - } - } - - // Get all used KindOfQuantities from the iModel, and populate the formatSet. - const ecsqlQuery = ` - SELECT - ks.Name || '.' || k.Name AS kindOfQuantityFullName, - COUNT(*) AS propertyCount, - json_group_array(p.Name) AS propertyNames - FROM - ECDbMeta.ECPropertyDef p - JOIN ECDbMeta.KindOfQuantityDef k ON k.ECInstanceId = p.KindOfQuantity.Id - JOIN ECDbMeta.ECSchemaDef ks ON ks.ECInstanceId = k.Schema.Id - GROUP BY - ks.Name, - k.Name - ORDER BY - propertyCount DESC; - `; - const reader = iModel.createQueryReader(ecsqlQuery); - const allRows = await reader.toArray(); - for (const row of allRows) { - const formatName = row[0]; - const format = await schemaFormatsProvider.getFormat(formatName); - if (format) { - if (format.label) { - if (usedLabels.has(format.label)) { - const schemaName = formatName.split(".")[0]; - (format as any).label = `${format.label} (${schemaName})`; - } - usedLabels.add(format.label); - } - schemaFormatSet.formats[formatName] = format; - } - } - - // Set this as the active format set if we found any formats - if (Object.keys(schemaFormatSet.formats).length > 0) { - this._iModelOpened = true; - this.setActiveFormatSet(schemaFormatSet); - - console.log(`Created schema-based format set with ${Object.keys(schemaFormatSet.formats).length} formats`); - } else { - console.log("No KindOfQuantity items found in known schemas"); - } - } catch (error) { - console.error("Failed to query schema items:", error); - } - } -} - -export class FormatSetFormatsProvider implements MutableFormatsProvider { - public onFormatsChanged: BeEvent<(args: FormatsChangedArgs) => void> = new BeEvent<(args: FormatsChangedArgs) => void>(); - - private _formatSet: FormatSet; - private _fallbackProvider?: FormatsProvider; - - public constructor(formatSet: FormatSet, fallbackProvider?: FormatsProvider) { - this._formatSet = formatSet; - this._fallbackProvider = fallbackProvider; - } - - public async addFormat(name: string, format: FormatDefinition): Promise { - this._formatSet.formats[name] = format; - this.onFormatsChanged.raiseEvent({ formatsChanged: [name] }); - } - - public clearFallbackProvider(): void { - this._fallbackProvider = undefined; - } - - public async getFormat(input: string): Promise { - // Normalizes any schemaItem names coming from node addon 'schemaName:schemaItemName' -> 'schemaName.schemaItemName' - const [schemaName, itemName] = SchemaItem.parseFullName(input); - - const name = schemaName === "" ? itemName : `${schemaName}.${itemName}`; - const format = this._formatSet.formats[name]; - if (format) return format; - if (this._fallbackProvider) return this._fallbackProvider.getFormat(name); - return undefined; - } - - public async removeFormat(name: string): Promise { - delete this._formatSet.formats[name]; - this.onFormatsChanged.raiseEvent({ formatsChanged: [name] }); - } -} diff --git a/apps/test-viewer/src/components/QuantityFormatButton.tsx b/apps/test-viewer/src/components/QuantityFormatButton.tsx index 797ea40a72..fe92ad436f 100644 --- a/apps/test-viewer/src/components/QuantityFormatButton.tsx +++ b/apps/test-viewer/src/components/QuantityFormatButton.tsx @@ -4,11 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import React, { useCallback, useState } from "react"; -import { FormatSelector, QuantityFormatPanel } from "@itwin/quantity-formatting-react"; +import { FormatManager, FormatSelector, QuantityFormatPanel } from "@itwin/quantity-formatting-react"; import { IModelApp } from "@itwin/core-frontend"; import type { FormatDefinition } from "@itwin/core-quantity"; import { Button, Modal, ModalButtonBar } from "@itwin/itwinui-react"; -import { FormatManager } from "./FormatManager"; import type { FormatSet } from "@itwin/ecschema-metadata"; @@ -52,8 +51,8 @@ export const QuantityFormatButton: React.FC = () => { // Listen for active format set changes React.useEffect(() => { - const removeFormatSetListener = FormatManager.instance?.onActiveFormatSetChanged.addListener((formatSet) => { - setActiveFormatSet(formatSet); + const removeFormatSetListener = FormatManager.instance?.onActiveFormatSetChanged.addListener((args: { currentFormatSet: FormatSet }) => { + setActiveFormatSet(args.currentFormatSet); setActiveFormatDefinitionKey(undefined); // Reset selection when format set changes }); return () => { diff --git a/apps/test-viewer/src/components/Viewer.tsx b/apps/test-viewer/src/components/Viewer.tsx index 569be089fc..a00d400abc 100644 --- a/apps/test-viewer/src/components/Viewer.tsx +++ b/apps/test-viewer/src/components/Viewer.tsx @@ -16,10 +16,9 @@ import { getUiProvidersConfig } from "../UiProvidersConfig"; import { ApiKeys } from "./ApiKeys"; import { useAuthorizationContext } from "./Authorization"; import { statusBarActionsProvider, ViewerOptionsProvider } from "./ViewerOptions"; -import { FormatManager } from "./FormatManager"; +import { FormatManager, QuantityFormatting } from "@itwin/quantity-formatting-react"; import type { UiProvidersConfig } from "../UiProvidersConfig"; -import { QuantityFormatting } from "@itwin/quantity-formatting-react"; export function Viewer() { return ( @@ -40,7 +39,10 @@ function ViewerWithOptions() { await FrontendDevTools.initialize(); await QuantityFormatting.startup(); // Initialize FormatManager with sample format sets - await FormatManager.initialize([]); + await FormatManager.initialize({ + formatSets: [], + setupSchemaFormatSetOnIModelOpen: true, + }); // ArcGIS Oauth setup const accessClient = new ArcGisAccessClient(); accessClient.initialize({ diff --git a/change/@itwin-quantity-formatting-react-31f886bb-b3ee-4702-be02-d2301a050417.json b/change/@itwin-quantity-formatting-react-31f886bb-b3ee-4702-be02-d2301a050417.json new file mode 100644 index 0000000000..77d79557c2 --- /dev/null +++ b/change/@itwin-quantity-formatting-react-31f886bb-b3ee-4702-be02-d2301a050417.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add formatManager API", + "packageName": "@itwin/quantity-formatting-react", + "email": "50554904+hl662@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/itwin/quantity-formatting/README.md b/packages/itwin/quantity-formatting/README.md index 60d2db0b1a..a9dbae1ce7 100644 --- a/packages/itwin/quantity-formatting/README.md +++ b/packages/itwin/quantity-formatting/README.md @@ -2,6 +2,37 @@ React components for quantity formatting in iTwin.js applications. +- [@itwin/quantity-formatting-react](#itwinquantity-formatting-react) + - [Description](#description) + - [Installation](#installation) + - [Common Worfklow](#common-worfklow) + - [Example Workflow](#example-workflow) + - [Components](#components) + - [QuantityFormatPanel](#quantityformatpanel) + - [QuantityFormatPanel Properties](#quantityformatpanel-properties) + - [QuantityFormatPanel Usage](#quantityformatpanel-usage) + - [FormatPanel](#formatpanel) + - [FormatPanel Properties](#formatpanel-properties) + - [FormatPanel Usage](#formatpanel-usage) + - [FormatSample](#formatsample) + - [FormatSample Properties](#formatsample-properties) + - [FormatSample Usage](#formatsample-usage) + - [FormatSelector](#formatselector) + - [FormatSelector Properties](#formatselector-properties) + - [FormatSelector Usage](#formatselector-usage) + - [Complete Example](#complete-example) + - [API](#api) + - [FormatManager](#formatmanager) + - [Key Features](#key-features) + - [Quick Start](#quick-start) + - [Initialization Options](#initialization-options) + - [Format Set Management](#format-set-management) + - [iModel Lifecycle Integration](#imodel-lifecycle-integration) + - [Event Handling](#event-handling) + - [Initialization](#initialization) + - [Integration with iModel Workflow](#integration-with-imodel-workflow) + - [License](#license) + ## Description This package provides React components for working with quantities and their formatting in iTwin.js applications. It includes components for configuring, displaying, and converting quantities with proper unit handling and formatting options. @@ -345,18 +376,176 @@ function QuantityFormatDialog({ formatSet }: { formatSet?: FormatSet }) { +## API + +### FormatManager + +The `FormatManager` provides a centralized singleton for managing format sets and format providers in iTwin applications. It automatically integrates with iModel schemas and supports custom format sets with event-driven updates. + +#### Key Features + +- **Singleton Pattern**: Single instance for application-wide format management +- **Format Set Management**: Add, remove, and organize collections of formats +- **Schema Integration**: Automatically discover and load formats from iModel schemas +- **Event-Driven**: Real-time notifications when format sets or active formats change +- **Fallback Support**: Configurable fallback format providers + +#### Quick Start + +```typescript +import { FormatManager } from "@itwin/quantity-formatting-react"; + +// Initialize the FormatManager during application startup +await FormatManager.initialize({ + formatSets: [myCustomFormatSet], + setupSchemaFormatSetOnIModelOpen: true, // Auto-setup from schemas +}); + +// Listen for format changes +FormatManager.instance?.onActiveFormatSetChanged.addListener((args) => { + console.log(`Format set changed to: ${args.currentFormatSet.name}`); +}); + +// Set an active format set +FormatManager.instance?.setActiveFormatSet(myFormatSet); +``` + +#### Initialization Options + +The `FormatManager.initialize()` method accepts these configuration options: + +```typescript +interface FormatManagerInitializeOptions { + /** Initial format sets to register */ + formatSets?: FormatSet[]; + + /** Fallback format provider for unknown formats */ + fallbackProvider?: FormatsProvider; + + /** Whether to automatically setup schema formats when iModel opens (default: true) */ + setupSchemaFormatSetOnIModelOpen?: boolean; + + /** Schema names to scan for formats (default: ["AecUnits"]) */ + schemaNames?: string[]; +} +``` + +#### Format Set Management + +```typescript +const manager = FormatManager.instance!; + +// Add a new format set +manager.addFormatSet({ + name: "MyFormats", + label: "My Custom Formats", + formats: { + "custom.length": { + type: "Decimal", + precision: 3, + label: "Custom Length Format", + }, + }, +}); + +// Get all format sets +const formatSets = manager.formatSets; + +// Get specific format set +const myFormatSet = manager.getFormatSet("MyFormats"); + +// Remove a format set +manager.removeFormatSet("MyFormats"); + +// Set active format set (applies to IModelApp.formatsProvider) +manager.setActiveFormatSet(myFormatSet); +``` + +#### iModel Lifecycle Integration + +The FormatManager automatically integrates with iModel lifecycle events: + +```typescript +// Called when iModel opens - automatically sets up schema formats +await manager.onIModelOpen(iModelConnection, { + schemaNames: ["AecUnits"], // Get all KindOfQuantities from these schemas + excludeUsedKindOfQuantities: false, // Include formats from used KindOfQuantities from all schemas across the iModel, not just those passed to schemaNames + formatSetLabel: "Schema Formats", // Label for the auto-generated format set +}); + +// Called when iModel closes - cleans up schema providers and event listeners +await manager.onIModelClose(); +``` + +#### Event Handling + +The FormatManager provides several events for monitoring changes: + +```typescript +const manager = FormatManager.instance!; + +// Listen for format set collection changes +manager.onFormatSetsChanged.addListener((formatSets) => { + console.log(`Now have ${formatSets.length} format sets`); +}); + +// Listen for active format set changes +manager.onActiveFormatSetChanged.addListener((args) => { + console.log(`Active format set changed from ${args.previousFormatSet?.name} to ${args.currentFormatSet.name}`); +}); + +// FormatSetFormatsProvider also fires events when individual formats change +const provider = manager.activeFormatSetFormatsProvider; +provider?.onFormatsChanged.addListener((args) => { + console.log(`Formats changed: ${args.formatsChanged.join(", ")}`); +}); +``` + ## Initialization -Before using the components, initialize the localization support: +Before using the components, initialize both the localization support and optionally the FormatManager: ```typescript -import { QuantityFormatting } from "@itwin/quantity-formatting-react"; +import { QuantityFormatting, FormatManager } from "@itwin/quantity-formatting-react"; import { IModelApp } from "@itwin/core-frontend"; -// Initialize during application startup +// Initialize localization during application startup await QuantityFormatting.startup({ localization: IModelApp.localization, // Optional: use custom localization }); + +// Optionally initialize FormatManager for centralized format management +await FormatManager.initialize({ + formatSets: [], // Start with empty format sets, or provide custom ones + setupSchemaFormatSetOnIModelOpen: true, // Auto-setup from iModel schemas +}); +``` + +### Integration with iModel Workflow + +For applications that work with iModels, integrate the FormatManager with your iModel lifecycle: + +```typescript +import { IModelConnection } from "@itwin/core-frontend"; + +// When opening an iModel +IModelConnection.open(...).then(async (iModelConnection) => { + // Setup schema-based formats automatically + await FormatManager.instance?.onIModelOpen(iModelConnection); + + // Your application logic here +}); + +// When closing an iModel +iModelConnection.close().then(async () => { + // Cleanup schema providers + await FormatManager.instance?.onIModelClose(); +}); + +// Application shutdown +process.on('exit', () => { + FormatManager.terminate(); // Cleanup singleton and listeners +}); ``` diff --git a/packages/itwin/quantity-formatting/api/quantity-formatting-react.api.md b/packages/itwin/quantity-formatting/api/quantity-formatting-react.api.md index e178adb443..8e4a55d43b 100644 --- a/packages/itwin/quantity-formatting/api/quantity-formatting-react.api.md +++ b/packages/itwin/quantity-formatting/api/quantity-formatting-react.api.md @@ -4,13 +4,49 @@ ```ts +import { BeEvent } from '@itwin/core-bentley'; import { FormatDefinition } from '@itwin/core-quantity'; +import type { FormatsChangedArgs } from '@itwin/core-quantity'; import type { FormatSet } from '@itwin/ecschema-metadata'; +import type { FormatsProvider } from '@itwin/core-quantity'; +import type { IModelConnection } from '@itwin/core-frontend'; import type { Localization } from '@itwin/core-common'; +import type { MutableFormatsProvider } from '@itwin/core-quantity'; import * as React_2 from 'react'; import { UnitProps } from '@itwin/core-quantity'; import type { UnitsProvider } from '@itwin/core-quantity'; +// @beta +export class FormatManager { + [Symbol.dispose](): void; + constructor(options: FormatManagerInitializeOptions); + get activeFormatSet(): FormatSet | undefined; + get activeFormatSetFormatsProvider(): FormatSetFormatsProvider | undefined; + addFormatSet(formatSet: FormatSet): void; + get fallbackFormatsProvider(): FormatsProvider | undefined; + set fallbackFormatsProvider(provider: FormatsProvider | undefined); + get formatSets(): FormatSet[]; + set formatSets(formatSets: FormatSet[]); + getFormatSet(name: string): FormatSet | undefined; + static initialize(options: FormatManagerInitializeOptions): Promise; + static get instance(): FormatManager | undefined; + readonly onActiveFormatSetChanged: BeEvent<(args: FormatSetChangedEventArgs) => void>; + readonly onFormatSetsChanged: BeEvent<(formatSets: FormatSet[]) => void>; + onIModelClose(): Promise; + onIModelOpen(iModel: IModelConnection, options?: OnIModelOpenOptions): Promise; + removeFormatSet(name: string): boolean; + setActiveFormatSet(formatSet: FormatSet): void; + static terminate(): void; +} + +// @beta +export interface FormatManagerInitializeOptions { + fallbackProvider?: FormatsProvider; + formatSets: FormatSet[]; + schemaNames?: string[]; + setupSchemaFormatSetOnIModelOpen?: boolean; +} + // @beta export function FormatPanel(props: FormatPanelProps): React_2.JSX.Element; @@ -54,6 +90,36 @@ interface FormatSelectorProps { onListItemChange: (formatDefinition: FormatDefinition, key: string) => void; } +// @beta +export interface FormatSetChangedEventArgs { + currentFormatSet: FormatSet; + previousFormatSet?: FormatSet; +} + +// @beta +export class FormatSetFormatsProvider implements MutableFormatsProvider { + constructor(formatSet: FormatSet, fallbackProvider?: FormatsProvider); + addFormat(name: string, format: FormatDefinition): Promise; + clearFallbackProvider(): void; + get fallbackProvider(): FormatsProvider | undefined; + get formatSet(): FormatSet; + getFormat(input: string): Promise; + readonly onFormatsChanged: BeEvent<(args: FormatsChangedArgs) => void>; + removeFormat(name: string): Promise; +} + +// @beta +export function getUsedKindOfQuantitiesFromIModel(iModel: IModelConnection): Promise<{ + kindOfQuantityFullName: string; +}[]>; + +// @beta +export interface OnIModelOpenOptions { + excludeUsedKindOfQuantities?: boolean; + formatSetLabel?: string; + schemaNames?: string[]; +} + // @beta export function QuantityFormatPanel(props: QuantityFormatPanelProps): React_2.JSX.Element; diff --git a/packages/itwin/quantity-formatting/api/quantity-formatting-react.exports.csv b/packages/itwin/quantity-formatting/api/quantity-formatting-react.exports.csv index 1836dc46e2..0cfcbaa0ec 100644 --- a/packages/itwin/quantity-formatting/api/quantity-formatting-react.exports.csv +++ b/packages/itwin/quantity-formatting/api/quantity-formatting-react.exports.csv @@ -1,7 +1,13 @@ sep=; Release Tag;API Item Type;API Item Name +beta;class;FormatManager +beta;interface;FormatManagerInitializeOptions beta;function;FormatPanel beta;function;FormatSample beta;const;FormatSelector +beta;interface;FormatSetChangedEventArgs +beta;class;FormatSetFormatsProvider +beta;function;getUsedKindOfQuantitiesFromIModel +beta;interface;OnIModelOpenOptions beta;function;QuantityFormatPanel beta;class;QuantityFormatting \ No newline at end of file diff --git a/packages/itwin/quantity-formatting/src/api/FormatManager.ts b/packages/itwin/quantity-formatting/src/api/FormatManager.ts new file mode 100644 index 0000000000..6bcefd0ad0 --- /dev/null +++ b/packages/itwin/quantity-formatting/src/api/FormatManager.ts @@ -0,0 +1,497 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { BeEvent } from "@itwin/core-bentley"; +import type { IModelConnection } from "@itwin/core-frontend"; +import { IModelApp } from "@itwin/core-frontend"; +import type { FormatDefinition, FormatsChangedArgs, FormatsProvider, MutableFormatsProvider } from "@itwin/core-quantity"; +import type { FormatSet } from "@itwin/ecschema-metadata"; +import { SchemaFormatsProvider, SchemaItem, SchemaItemType, SchemaKey, SchemaMatchType } from "@itwin/ecschema-metadata"; +import { getUsedKindOfQuantitiesFromIModel } from "./Utils.js"; + +/** + * Event arguments for format set change events. + * @beta + */ +export interface FormatSetChangedEventArgs { + /** The previously active format set, if any */ + previousFormatSet?: FormatSet; + /** The newly active format set */ + currentFormatSet: FormatSet; +} + +/** + * Options for initializing the FormatManager. + * @beta + */ +export interface FormatManagerInitializeOptions { + /** Initial format sets to load */ + formatSets: FormatSet[]; + /** Optional fallback formats provider */ + fallbackProvider?: FormatsProvider; + /** Whether to automatically set up schema formats when an iModel is opened */ + setupSchemaFormatSetOnIModelOpen?: boolean; + /** Schema names to scan for KindOfQuantity definitions */ + schemaNames?: string[]; +} + +/** + * Options for populating a format set of KindOfQuantities in an iModel when it is open + * @beta + */ +export interface OnIModelOpenOptions { + /** Schema names to scan for formats. Defaults to ["AecUnits"] */ + schemaNames?: string[]; + /** Custom label for the generated format set */ + formatSetLabel?: string; + /** Whether to exclude used KindOfQuantities */ + excludeUsedKindOfQuantities?: boolean; +} + +/** + * A centralized manager for handling format sets and format providers in iTwin applications. + * This class provides a singleton pattern for managing quantity formatting throughout the application, + * with automatic integration with iModel schemas and support for custom format sets. + * + * @example + * ```typescript + * // Initialize the format manager + * await FormatManager.initialize({ + * formatSets: [myCustomFormatSet], + * setupSchemaFormatSetOnIModelOpen: true + * }); + * + * // Listen for format set changes + * FormatManager.instance.onActiveFormatSetChanged.addListener((args) => { + * console.log(`Format set changed to: ${args.currentFormatSet.name}`); + * }); + * + * // Set an active format set + * FormatManager.instance.setActiveFormatSet(myFormatSet); + * ``` + * + * @beta + */ +export class FormatManager { + private static _instance?: FormatManager; + private _formatSets: FormatSet[] = []; + private _fallbackFormatProvider?: FormatsProvider; + private _activeFormatSet?: FormatSet; + private _activeFormatSetFormatsProvider?: FormatSetFormatsProvider; + private _iModelOpened: boolean = false; + private _removeListeners: (() => void)[] = []; + private _options: FormatManagerInitializeOptions; + + /** + * Event raised when the active format set changes. + */ + public readonly onActiveFormatSetChanged = new BeEvent<(args: FormatSetChangedEventArgs) => void>(); + + /** + * Event raised when format sets are updated. + */ + public readonly onFormatSetsChanged = new BeEvent<(formatSets: FormatSet[]) => void>(); + + /** + * Gets the singleton instance of the FormatManager. + * @returns The FormatManager instance, or undefined if not initialized. + */ + public static get instance(): FormatManager | undefined { + return this._instance; + } + + /** + * All available format sets. + */ + public get formatSets(): FormatSet[] { + return [...this._formatSets]; + } + + public set formatSets(formatSets: FormatSet[]) { + this._formatSets = [...formatSets]; + this.onFormatSetsChanged.raiseEvent(this._formatSets); + } + + /** + * Gets the currently active format set. + */ + public get activeFormatSet(): FormatSet | undefined { + return this._activeFormatSet; + } + + /** + * Gets the active format set's formats provider. + */ + public get activeFormatSetFormatsProvider(): FormatSetFormatsProvider | undefined { + return this._activeFormatSetFormatsProvider; + } + + /** + * The fallback formats provider. + * Typically used to enable a SchemaFormatsProvider as the fallback during runtime. + */ + public get fallbackFormatsProvider(): FormatsProvider | undefined { + return this._fallbackFormatProvider; + } + + public set fallbackFormatsProvider(provider: FormatsProvider | undefined) { + this._fallbackFormatProvider = provider; + if (this._activeFormatSet) { + // If we have an active format set, update the formats provider to include the new fallback + const newFormatSetFormatsProvider = new FormatSetFormatsProvider(this._activeFormatSet, this._fallbackFormatProvider); + this._activeFormatSetFormatsProvider = newFormatSetFormatsProvider; + if (this._iModelOpened) { + IModelApp.formatsProvider = newFormatSetFormatsProvider; + } + } + } + + /** + * Initialize the FormatManager with the given options. + * @param options - Configuration options for the FormatManager. + * @throws Error if the FormatManager is already initialized. + */ + public static async initialize(options: FormatManagerInitializeOptions): Promise { + if (this._instance) { + throw new Error("FormatManager is already initialized. Call terminate() first if you need to reinitialize."); + } + + this._instance = new FormatManager(options); + } + + /** + * Terminates the FormatManager and cleans up all listeners. + */ + public static terminate(): void { + if (this._instance) { + this._instance[Symbol.dispose](); + this._instance = undefined; + } + } + + /** + * Creates a new FormatManager instance. + * @param options - Initialization options. + */ + public constructor(options: FormatManagerInitializeOptions) { + this._options = options; + this._formatSets = [...options.formatSets]; + this._fallbackFormatProvider = options.fallbackProvider; + } + + /** + * Cleanup method that removes all event listeners. + */ + public [Symbol.dispose](): void { + for (const listener of this._removeListeners) { + listener(); + } + this._removeListeners = []; + } + + /** + * Sets the active format set and updates the formats provider. + * @param formatSet - The format set to activate. + */ + public setActiveFormatSet(formatSet: FormatSet): void { + const previousFormatSet = this._activeFormatSet; + const formatSetFormatsProvider = new FormatSetFormatsProvider(formatSet, this._fallbackFormatProvider); + + this._activeFormatSet = formatSet; + this._activeFormatSetFormatsProvider = formatSetFormatsProvider; + + if (this._iModelOpened) { + IModelApp.formatsProvider = formatSetFormatsProvider; + } + + this.onActiveFormatSetChanged.raiseEvent({ + previousFormatSet, + currentFormatSet: formatSet, + }); + } + + /** + * Adds a new format set to the available format sets. + * @param formatSet - The format set to add. + */ + public addFormatSet(formatSet: FormatSet): void { + // Check if format set with same name already exists + const existingIndex = this._formatSets.findIndex(fs => fs.name === formatSet.name); + if (existingIndex >= 0) { + this._formatSets[existingIndex] = formatSet; + } else { + this._formatSets.push(formatSet); + } + this.onFormatSetsChanged.raiseEvent(this._formatSets); + } + + /** + * Removes a format set by name. + * @param name - The name of the format set to remove. + * @returns True if the format set was found and removed, false otherwise. + */ + public removeFormatSet(name: string): boolean { + const index = this._formatSets.findIndex(fs => fs.name === name); + if (index >= 0) { + this._formatSets.splice(index, 1); + + // If this was the active format set, clear it + if (this._activeFormatSet?.name === name) { + this._activeFormatSet = undefined; + this._activeFormatSetFormatsProvider = undefined; + if (this._iModelOpened && this._fallbackFormatProvider) { + IModelApp.formatsProvider = this._fallbackFormatProvider; + } + } + + this.onFormatSetsChanged.raiseEvent(this._formatSets); + return true; + } + return false; + } + + /** + * Finds a format set by name. + * @param name - The name of the format set to find. + * @returns The format set if found, undefined otherwise. + */ + public getFormatSet(name: string): FormatSet | undefined { + return this._formatSets.find(fs => fs.name === name); + } + + /** + * Called when an iModel is closed. Cleans up resources and listeners. + */ + public async onIModelClose(): Promise { + // Clean up listeners + this._removeListeners.forEach((removeListener) => removeListener()); + this._removeListeners = []; + + // Clear fallback provider if it's schema-based + if (this._fallbackFormatProvider && this._fallbackFormatProvider instanceof SchemaFormatsProvider) { + this._fallbackFormatProvider = undefined; + if (this._activeFormatSetFormatsProvider) { + this._activeFormatSetFormatsProvider.clearFallbackProvider(); + } + } + + this._iModelOpened = false; + } + + /** + * Called when an iModel is opened. Sets up schema-based formats if enabled. + * @param iModel - The opened iModel connection. + * @param options - Optional configuration for schema format setup. + */ + public async onIModelOpen(iModel: IModelConnection, options?: OnIModelOpenOptions): Promise { + // Set up schema-based units and formats providers + const schemaFormatsProvider = new SchemaFormatsProvider(iModel.schemaContext, IModelApp.quantityFormatter.activeUnitSystem); + this.fallbackFormatsProvider = schemaFormatsProvider; + + this._removeListeners.push( + IModelApp.quantityFormatter.onActiveFormattingUnitSystemChanged.addListener((args) => { + schemaFormatsProvider.unitSystem = args.system; + }) + ); + + // Set up schema formats if auto-setup is enabled + if (this._options.setupSchemaFormatSetOnIModelOpen !== false) { + await this._setupSchemaFormats(iModel, schemaFormatsProvider, options); + } + + this._iModelOpened = true; + + // Apply active format set if we have one + if (this._activeFormatSet && this._activeFormatSetFormatsProvider) { + IModelApp.formatsProvider = this._activeFormatSetFormatsProvider; + } + } + + /** + * Sets up formats from iModel schemas. + * @param iModel - The iModel connection. + * @param schemaFormatsProvider - The schema formats provider. + * @param options - Query options. + */ + private async _setupSchemaFormats( + iModel: IModelConnection, + schemaFormatsProvider: SchemaFormatsProvider, + options?: OnIModelOpenOptions + ): Promise { + try { + const schemaFormatSet: FormatSet = { + name: "SchemaFormats", + label: options?.formatSetLabel ?? "Formats coming from current open iModel", + formats: {}, + }; + + const usedLabels: Set = new Set(); + const schemaNames = options?.schemaNames ?? this._options.schemaNames ?? ["AecUnits"]; + + // Get formats from known schemas + for (const schemaName of schemaNames) { + try { + const schema = await iModel.schemaContext.getSchema(new SchemaKey(schemaName, SchemaMatchType.Latest)); + if (schema) { + for (const schemaItem of schema.getItems()) { + if (schemaItem.schemaItemType === SchemaItemType.KindOfQuantity) { + const format = await schemaFormatsProvider.getFormat(schemaItem.fullName); + if (format) { + this._processFormatLabel(format, schemaItem, usedLabels); + schemaFormatSet.formats[schemaItem.fullName] = format; + } + } + } + } + } catch (error) { + // Schema not found, continue with others + console.warn(`Schema ${schemaName} not found or failed to load:`, error); + } + } + + // Get all used KindOfQuantities from the iModel if usage stats are requested + if (!options?.excludeUsedKindOfQuantities) { + await this._addUsedFormatsFromIModel(iModel, schemaFormatsProvider, schemaFormatSet, usedLabels); + } + + // Add the schema format set if we found any formats + if (Object.keys(schemaFormatSet.formats).length > 0) { + this.addFormatSet(schemaFormatSet); + + // Set as active if no other format set is active + if (!this._activeFormatSet) { + this.setActiveFormatSet(schemaFormatSet); + } + } + } catch (error) { + // Failed to set up schema formats, continue without them + } + } + + /** + * Processes format labels to ensure uniqueness. + */ + private _processFormatLabel(format: FormatDefinition, schemaItem: any, usedLabels: Set): void { + if (format.label) { + if (usedLabels.has(format.label)) { + (format as any).label = `${format.label} (${schemaItem.key.schemaName})`; + } + usedLabels.add(format.label); + } + } + + /** + * Adds formats that are actually used in the iModel. + */ + private async _addUsedFormatsFromIModel( + iModel: IModelConnection, + schemaFormatsProvider: SchemaFormatsProvider, + schemaFormatSet: FormatSet, + usedLabels: Set + ): Promise { + try { + const koqs = await getUsedKindOfQuantitiesFromIModel(iModel); + + for (const row of koqs) { + const formatName = row.kindOfQuantityFullName; + const format = await schemaFormatsProvider.getFormat(formatName); + if (format && !schemaFormatSet.formats[formatName]) { + if (format.label && usedLabels.has(format.label)) { + const schemaName = formatName.split(".")[0]; + (format as any).label = `${format.label} (${schemaName})`; + } + if (format.label) { + usedLabels.add(format.label); + } + schemaFormatSet.formats[formatName] = format; + } + } + } catch (error) { + // Query failed, continue without usage stats + } + } +} + +/** + * A formats provider that uses a FormatSet and optionally falls back to another provider. + * + * @important This will be replaced by FormatSetFormatsProvider from @itwin/ecschema-metadata in 5.2. + * @beta + */ +export class FormatSetFormatsProvider implements MutableFormatsProvider { + /** + * Event raised when formats in the set are changed. + */ + public readonly onFormatsChanged = new BeEvent<(args: FormatsChangedArgs) => void>(); + + private _formatSet: FormatSet; + private _fallbackProvider?: FormatsProvider; + + /** + * Creates a new FormatSetFormatsProvider. + * @param formatSet - The format set to provide formats from. + * @param fallbackProvider - Optional fallback provider for formats not in the set. + */ + public constructor(formatSet: FormatSet, fallbackProvider?: FormatsProvider) { + this._formatSet = formatSet; + this._fallbackProvider = fallbackProvider; + } + + /** + * Adds a format to the format set. + * @param name - The name of the format. + * @param format - The format definition. + */ + public async addFormat(name: string, format: FormatDefinition): Promise { + this._formatSet.formats[name] = format; + this.onFormatsChanged.raiseEvent({ formatsChanged: [name] }); + } + + /** + * Removes the fallback provider. + */ + public clearFallbackProvider(): void { + this._fallbackProvider = undefined; + } + + /** + * Gets a format by name from the format set or fallback provider. + * @param input - The format name or schema item name. + * @returns The format definition if found, undefined otherwise. + */ + public async getFormat(input: string): Promise { + // Normalizes any schemaItem names coming from node addon 'schemaName:schemaItemName' -> 'schemaName.schemaItemName' + const [schemaName, itemName] = SchemaItem.parseFullName(input); + const name = schemaName === "" ? itemName : `${schemaName}.${itemName}`; + + const format = this._formatSet.formats[name]; + if (format) return format; + if (this._fallbackProvider) return this._fallbackProvider.getFormat(name); + return undefined; + } + + /** + * Removes a format from the format set. + * @param name - The name of the format to remove. + */ + public async removeFormat(name: string): Promise { + delete this._formatSet.formats[name]; + this.onFormatsChanged.raiseEvent({ formatsChanged: [name] }); + } + + /** + * Gets the underlying format set. + */ + public get formatSet(): FormatSet { + return this._formatSet; + } + + /** + * Gets the fallback provider. + */ + public get fallbackProvider(): FormatsProvider | undefined { + return this._fallbackProvider; + } +} diff --git a/packages/itwin/quantity-formatting/src/api/Utils.ts b/packages/itwin/quantity-formatting/src/api/Utils.ts new file mode 100644 index 0000000000..1c7ad8fb9d --- /dev/null +++ b/packages/itwin/quantity-formatting/src/api/Utils.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import type { IModelConnection } from "@itwin/core-frontend"; + +/** + * Queries an iModel to get all KindOfQuantity items that are actually used by properties + * + * @param iModel - The iModel connection to query, used to create a queryReader. + * @returns Array of used KindOfQuantity full names. + * + * @example + * ```typescript + * const usageInfo = await getUsedKindOfQuantitiesFromIModel(iModel); + * console.log(`Found ${usageInfo.length} used KindOfQuantities`); + * usageInfo.forEach(info => { + * console.log(`${info.kindOfQuantityFullName}`); + * }); + * ``` + * @beta + */ +export async function getUsedKindOfQuantitiesFromIModel(iModel: IModelConnection): Promise<{ kindOfQuantityFullName: string }[]> { + const ecsqlQuery = ` + SELECT + ks.Name || '.' || k.Name AS kindOfQuantityFullName + FROM + ECDbMeta.ECPropertyDef p + JOIN ECDbMeta.KindOfQuantityDef k ON k.ECInstanceId = p.KindOfQuantity.Id + JOIN ECDbMeta.ECSchemaDef ks ON ks.ECInstanceId = k.Schema.Id + GROUP BY + ks.Name, + k.Name + `; + + try { + const reader = iModel.createQueryReader(ecsqlQuery); + const allRows = await reader.toArray(); + + return allRows.map(row => { + return { + kindOfQuantityFullName: row[0] as string + }; + }); + } catch (error) { + // Query failed, return empty array + console.warn("Failed to query used KindOfQuantities from iModel:", error); + return []; + } +} diff --git a/packages/itwin/quantity-formatting/src/quantity-formatting-react.ts b/packages/itwin/quantity-formatting/src/quantity-formatting-react.ts index ff27bb7266..c7b3a3f06f 100644 --- a/packages/itwin/quantity-formatting/src/quantity-formatting-react.ts +++ b/packages/itwin/quantity-formatting/src/quantity-formatting-react.ts @@ -11,6 +11,20 @@ // Export main class for localization initialization export { QuantityFormatting } from "./QuantityFormatting.js"; +// Export API classes +export { + FormatManager, + FormatSetFormatsProvider, + type FormatManagerInitializeOptions, + type FormatSetChangedEventArgs, + type OnIModelOpenOptions, +} from "./api/FormatManager.js"; + +// Export utility functions +export { + getUsedKindOfQuantitiesFromIModel +} from "./api/Utils.js"; + // Export React components here export { QuantityFormatPanel } from "./components/quantityformat/QuantityFormatPanel.js"; export { FormatSample } from "./components/quantityformat/FormatSample.js"; diff --git a/packages/itwin/quantity-formatting/src/test/FormatManager.test.ts b/packages/itwin/quantity-formatting/src/test/FormatManager.test.ts new file mode 100644 index 0000000000..067b4fc5c4 --- /dev/null +++ b/packages/itwin/quantity-formatting/src/test/FormatManager.test.ts @@ -0,0 +1,569 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BeEvent } from "@itwin/core-bentley"; +import { IModelApp } from "@itwin/core-frontend"; +import type { FormatDefinition, FormatsProvider } from "@itwin/core-quantity"; +import type { FormatSet } from "@itwin/ecschema-metadata"; +import { FormatManager, FormatSetFormatsProvider } from "../api/FormatManager.js"; +import { getUsedKindOfQuantitiesFromIModel } from "../api/Utils.js"; + +// Mock dependencies +vi.mock("@itwin/core-frontend", () => ({ + IModelApp: { + formatsProvider: undefined, + quantityFormatter: { + activeUnitSystem: "metric", + onActiveFormattingUnitSystemChanged: new BeEvent(), + }, + }, +})); + +vi.mock("@itwin/ecschema-metadata", () => ({ + SchemaFormatsProvider: vi.fn(), + SchemaItem: { + parseFullName: vi.fn((name: string) => { + const parts = name.split("."); + return parts.length === 2 ? [parts[0], parts[1]] : ["", name]; + }), + }, + SchemaItemType: { + KindOfQuantity: "KindOfQuantity", + }, + SchemaKey: vi.fn(), + SchemaMatchType: { + Latest: "Latest", + }, +})); + +describe("getUsedKindOfQuantitiesFromIModel", () => { + let mockIModel: any; + + beforeEach(() => { + mockIModel = { + createQueryReader: vi.fn(), + }; + }); + + it("should return KindOfQuantity full names", async () => { + const mockRows = [ + ["AecUnits.LENGTH"], + ["AecUnits.AREA"], + ["Units.VOLUME"], + ]; + + const mockReader = { + toArray: vi.fn().mockResolvedValue(mockRows), + }; + + mockIModel.createQueryReader.mockReturnValue(mockReader); + + const result = await getUsedKindOfQuantitiesFromIModel(mockIModel); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + kindOfQuantityFullName: "AecUnits.LENGTH", + }); + expect(result[1]).toEqual({ + kindOfQuantityFullName: "AecUnits.AREA", + }); + expect(result[2]).toEqual({ + kindOfQuantityFullName: "Units.VOLUME", + }); + }); + + it("should return empty array when query fails", async () => { + mockIModel.createQueryReader.mockImplementation(() => { + throw new Error("Query failed"); + }); + + const result = await getUsedKindOfQuantitiesFromIModel(mockIModel); + + expect(result).toEqual([]); + }); + + it("should return empty array when reader fails", async () => { + const mockReader = { + toArray: vi.fn().mockRejectedValue(new Error("Reader failed")), + }; + + mockIModel.createQueryReader.mockReturnValue(mockReader); + + const result = await getUsedKindOfQuantitiesFromIModel(mockIModel); + + expect(result).toEqual([]); + }); + + it("should handle empty result set", async () => { + const mockReader = { + toArray: vi.fn().mockResolvedValue([]), + }; + + mockIModel.createQueryReader.mockReturnValue(mockReader); + + const result = await getUsedKindOfQuantitiesFromIModel(mockIModel); + + expect(result).toEqual([]); + }); +}); + +describe("FormatManager", () => { + let testFormatSet1: FormatSet; + let testFormatSet2: FormatSet; + let mockFormatsProvider: FormatsProvider; + + beforeEach(() => { + // Reset the singleton instance before each test + FormatManager.terminate(); + + // Create test format sets + testFormatSet1 = { + name: "TestSet1", + label: "Test Format Set 1", + formats: { + "test.format1": { + type: "Decimal", + precision: 2, + label: "Test Format 1", + } as FormatDefinition, + "test.format2": { + type: "Decimal", + precision: 4, + label: "Test Format 2", + } as FormatDefinition, + }, + }; + + testFormatSet2 = { + name: "TestSet2", + label: "Test Format Set 2", + formats: { + "test.format3": { + type: "Scientific", + precision: 3, + label: "Test Format 3", + } as FormatDefinition, + }, + }; + + // Create mock formats provider + mockFormatsProvider = { + getFormat: vi.fn().mockResolvedValue(undefined), + } as unknown as FormatsProvider; + + // Reset IModelApp mock + (IModelApp as any).formatsProvider = undefined; + }); + + describe("Initialization", () => { + it("should initialize with format sets", async () => { + await FormatManager.initialize({ + formatSets: [testFormatSet1, testFormatSet2], + fallbackProvider: mockFormatsProvider, + }); + + const manager = FormatManager.instance; + expect(manager).toBeDefined(); + expect(manager!.formatSets).toHaveLength(2); + expect(manager!.formatSets[0].name).toBe("TestSet1"); + expect(manager!.formatSets[1].name).toBe("TestSet2"); + expect(manager!.fallbackFormatsProvider).toBe(mockFormatsProvider); + }); + + it("should throw error if already initialized", async () => { + await FormatManager.initialize({ + formatSets: [testFormatSet1], + }); + + await expect( + FormatManager.initialize({ + formatSets: [testFormatSet2], + }) + ).rejects.toThrow("FormatManager is already initialized"); + }); + + it("should return undefined instance when not initialized", () => { + expect(FormatManager.instance).toBeUndefined(); + }); + }); + + describe("Format Set Management", () => { + beforeEach(async () => { + await FormatManager.initialize({ + formatSets: [testFormatSet1], + }); + }); + + it("should set format sets", () => { + const manager = FormatManager.instance!; + const onFormatSetsChangedSpy = vi.fn(); + manager.onFormatSetsChanged.addListener(onFormatSetsChangedSpy); + + manager.formatSets = [testFormatSet1, testFormatSet2]; + + expect(manager.formatSets).toHaveLength(2); + expect(onFormatSetsChangedSpy).toHaveBeenCalledWith([testFormatSet1, testFormatSet2]); + }); + + it("should add format set", () => { + const manager = FormatManager.instance!; + const onFormatSetsChangedSpy = vi.fn(); + manager.onFormatSetsChanged.addListener(onFormatSetsChangedSpy); + + manager.addFormatSet(testFormatSet2); + + expect(manager.formatSets).toHaveLength(2); + expect(manager.getFormatSet("TestSet2")).toBe(testFormatSet2); + expect(onFormatSetsChangedSpy).toHaveBeenCalled(); + }); + + it("should replace existing format set with same name", () => { + const manager = FormatManager.instance!; + const updatedFormatSet = { ...testFormatSet1, label: "Updated Label" }; + + manager.addFormatSet(updatedFormatSet); + + expect(manager.formatSets).toHaveLength(1); + expect(manager.getFormatSet("TestSet1")!.label).toBe("Updated Label"); + }); + + it("should remove format set", () => { + const manager = FormatManager.instance!; + manager.addFormatSet(testFormatSet2); + + const result = manager.removeFormatSet("TestSet2"); + + expect(result).toBe(true); + expect(manager.formatSets).toHaveLength(1); + expect(manager.getFormatSet("TestSet2")).toBeUndefined(); + }); + + it("should return false when removing non-existent format set", () => { + const manager = FormatManager.instance!; + + const result = manager.removeFormatSet("NonExistent"); + + expect(result).toBe(false); + }); + + it("should clear active format set when removing it", () => { + const manager = FormatManager.instance!; + manager.setActiveFormatSet(testFormatSet1); + + manager.removeFormatSet("TestSet1"); + + expect(manager.activeFormatSet).toBeUndefined(); + expect(manager.activeFormatSetFormatsProvider).toBeUndefined(); + }); + }); + + describe("Active Format Set", () => { + beforeEach(async () => { + await FormatManager.initialize({ + formatSets: [testFormatSet1, testFormatSet2], + }); + }); + + it("should set active format set", () => { + const manager = FormatManager.instance!; + const onActiveFormatSetChangedSpy = vi.fn(); + manager.onActiveFormatSetChanged.addListener(onActiveFormatSetChangedSpy); + + manager.setActiveFormatSet(testFormatSet1); + + expect(manager.activeFormatSet).toBe(testFormatSet1); + expect(manager.activeFormatSetFormatsProvider).toBeDefined(); + expect(onActiveFormatSetChangedSpy).toHaveBeenCalledWith({ + previousFormatSet: undefined, + currentFormatSet: testFormatSet1, + }); + }); + + it("should change active format set", () => { + const manager = FormatManager.instance!; + const onActiveFormatSetChangedSpy = vi.fn(); + + manager.setActiveFormatSet(testFormatSet1); + manager.onActiveFormatSetChanged.addListener(onActiveFormatSetChangedSpy); + manager.setActiveFormatSet(testFormatSet2); + + expect(manager.activeFormatSet).toBe(testFormatSet2); + expect(onActiveFormatSetChangedSpy).toHaveBeenCalledWith({ + previousFormatSet: testFormatSet1, + currentFormatSet: testFormatSet2, + }); + }); + }); + + describe("Fallback Formats Provider", () => { + beforeEach(async () => { + await FormatManager.initialize({ + formatSets: [testFormatSet1], + }); + }); + + it("should set fallback formats provider", () => { + const manager = FormatManager.instance!; + + manager.fallbackFormatsProvider = mockFormatsProvider; + + expect(manager.fallbackFormatsProvider).toBe(mockFormatsProvider); + }); + + it("should update active format set provider when fallback changes", () => { + const manager = FormatManager.instance!; + manager.setActiveFormatSet(testFormatSet1); + const originalProvider = manager.activeFormatSetFormatsProvider; + + manager.fallbackFormatsProvider = mockFormatsProvider; + + expect(manager.activeFormatSetFormatsProvider).not.toBe(originalProvider); + expect(manager.activeFormatSetFormatsProvider!.fallbackProvider).toBe(mockFormatsProvider); + }); + }); + + describe("iModel Lifecycle", () => { + let mockIModel: any; + + beforeEach(async () => { + await FormatManager.initialize({ + formatSets: [testFormatSet1], + setupSchemaFormatSetOnIModelOpen: true, // Enable for testing schema format setup + }); + + mockIModel = { + schemaContext: { + getSchema: vi.fn().mockResolvedValue({ + getItems: vi.fn().mockReturnValue([]), + }), + }, + createQueryReader: vi.fn().mockReturnValue({ + toArray: vi.fn().mockResolvedValue([]), + }), + }; + }); + + it("should handle iModel open", async () => { + const manager = FormatManager.instance!; + manager.setActiveFormatSet(testFormatSet1); + + await manager.onIModelOpen(mockIModel); + + expect((IModelApp as any).formatsProvider).toBe(manager.activeFormatSetFormatsProvider); + }); + + it("should exclude used KindOfQuantities when option is set", async () => { + const manager = FormatManager.instance!; + + // Mock the private method to verify it's not called + const addUsedFormatsSpy = vi.spyOn(manager as any, "_addUsedFormatsFromIModel"); + + await manager.onIModelOpen(mockIModel, { + excludeUsedKindOfQuantities: true, + }); + + expect(addUsedFormatsSpy).not.toHaveBeenCalled(); + }); + + it("should include used KindOfQuantities by default", async () => { + const manager = FormatManager.instance!; + + // Mock the private method to verify it's called + const addUsedFormatsSpy = vi.spyOn(manager as any, "_addUsedFormatsFromIModel").mockResolvedValue(undefined); + + await manager.onIModelOpen(mockIModel); + + expect(addUsedFormatsSpy).toHaveBeenCalled(); + }); + + it("should include used KindOfQuantities when explicitly set to false", async () => { + const manager = FormatManager.instance!; + + // Mock the private method to verify it's called + const addUsedFormatsSpy = vi.spyOn(manager as any, "_addUsedFormatsFromIModel").mockResolvedValue(undefined); + + await manager.onIModelOpen(mockIModel, { + excludeUsedKindOfQuantities: false, + }); + + expect(addUsedFormatsSpy).toHaveBeenCalled(); + }); + + it("should handle iModel close", async () => { + const manager = FormatManager.instance!; + await manager.onIModelOpen(mockIModel); + + await manager.onIModelClose(); + + expect(manager.fallbackFormatsProvider).toBeUndefined(); + }); + }); + + describe("Cleanup", () => { + it("should cleanup listeners on dispose", async () => { + await FormatManager.initialize({ + formatSets: [testFormatSet1], + }); + + const manager = FormatManager.instance!; + const removeListenerSpy = vi.fn(); + (manager as any)._removeListeners.push(removeListenerSpy); + + manager[Symbol.dispose](); + + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it("should terminate and cleanup", async () => { + await FormatManager.initialize({ + formatSets: [testFormatSet1], + }); + + expect(FormatManager.instance).toBeDefined(); + + FormatManager.terminate(); + + expect(FormatManager.instance).toBeUndefined(); + }); + }); +}); + +describe("FormatSetFormatsProvider", () => { + let testFormatSet: FormatSet; + let mockFallbackProvider: FormatsProvider; + let provider: FormatSetFormatsProvider; + + beforeEach(() => { + testFormatSet = { + name: "TestSet", + label: "Test Format Set", + formats: { + "test.format1": { + type: "Decimal", + precision: 2, + label: "Test Format 1", + } as FormatDefinition, + }, + }; + + mockFallbackProvider = { + getFormat: vi.fn().mockImplementation((name: string) => { + if (name === "fallback.format") { + return Promise.resolve({ + type: "Decimal", + precision: 1, + label: "Fallback Format", + } as FormatDefinition); + } + return Promise.resolve(undefined); + }), + } as unknown as FormatsProvider; + + provider = new FormatSetFormatsProvider(testFormatSet, mockFallbackProvider); + }); + + describe("Format Retrieval", () => { + it("should get format from format set", async () => { + const format = await provider.getFormat("test.format1"); + + expect(format).toBeDefined(); + expect(format!.precision).toBe(2); + expect(format!.label).toBe("Test Format 1"); + }); + + it("should get format from fallback provider", async () => { + const format = await provider.getFormat("fallback.format"); + + expect(format).toBeDefined(); + expect(format!.precision).toBe(1); + expect(format!.label).toBe("Fallback Format"); + expect(mockFallbackProvider.getFormat).toHaveBeenCalledWith("fallback.format"); + }); + + it("should return undefined for non-existent format", async () => { + const format = await provider.getFormat("non.existent"); + + expect(format).toBeUndefined(); + }); + + it("should parse schema item names correctly", async () => { + // Mock SchemaItem.parseFullName to test name parsing + const { SchemaItem } = await import("@itwin/ecschema-metadata"); + const parseFullNameSpy = vi.mocked(SchemaItem.parseFullName).mockReturnValue(["test", "format1"]); + + const format = await provider.getFormat("test:format1"); + + expect(format).toBeDefined(); + expect(SchemaItem.parseFullName).toHaveBeenCalledWith("test:format1"); + + // Restore the original implementation + parseFullNameSpy.mockRestore(); + }); + }); + + describe("Format Modification", () => { + it("should add format", async () => { + const onFormatsChangedSpy = vi.fn(); + provider.onFormatsChanged.addListener(onFormatsChangedSpy); + + const newFormat: FormatDefinition = { + type: "Scientific", + precision: 3, + label: "New Format", + }; + + await provider.addFormat("test.newformat", newFormat); + + expect(testFormatSet.formats["test.newformat"]).toBe(newFormat); + expect(onFormatsChangedSpy).toHaveBeenCalledWith({ + formatsChanged: ["test.newformat"], + }); + }); + + it("should remove format", async () => { + const onFormatsChangedSpy = vi.fn(); + provider.onFormatsChanged.addListener(onFormatsChangedSpy); + + await provider.removeFormat("test.format1"); + + expect(testFormatSet.formats["test.format1"]).toBeUndefined(); + expect(onFormatsChangedSpy).toHaveBeenCalledWith({ + formatsChanged: ["test.format1"], + }); + }); + }); + + describe("Fallback Provider Management", () => { + it("should clear fallback provider", () => { + provider.clearFallbackProvider(); + + expect(provider.fallbackProvider).toBeUndefined(); + }); + + it("should access format set and fallback provider", () => { + expect(provider.formatSet).toBe(testFormatSet); + expect(provider.fallbackProvider).toBe(mockFallbackProvider); + }); + }); + + describe("Without Fallback Provider", () => { + beforeEach(() => { + provider = new FormatSetFormatsProvider(testFormatSet); + }); + + it("should work without fallback provider", async () => { + const format = await provider.getFormat("test.format1"); + expect(format).toBeDefined(); + + const nonExistentFormat = await provider.getFormat("non.existent"); + expect(nonExistentFormat).toBeUndefined(); + }); + + it("should have undefined fallback provider", () => { + expect(provider.fallbackProvider).toBeUndefined(); + }); + }); +});