diff --git a/src/4.3_to_5.0/__test_resources__/legacyComparisonValues.ts b/src/4.3_to_5.0/__test_resources__/legacyComparisonValues.ts index e0802c2d..5bea850e 100644 --- a/src/4.3_to_5.0/__test_resources__/legacyComparisonValues.ts +++ b/src/4.3_to_5.0/__test_resources__/legacyComparisonValues.ts @@ -9,8 +9,7 @@ export const legacyComparisonValues = { showTitleBar: true, body: { serverUrl: "http://localhost:9090", - mdx: - "SELECT NON EMPTY Hierarchize(DrilldownLevel([Currency].[Currency].[ALL].[AllMember])) ON ROWS, NON EMPTY [Measures].[pnl.FOREX] ON COLUMNS, {[Booking].[Desk].[ALL].[AllMember].[LegalEntityA], [Booking].[Desk].[ALL].[AllMember].[LegalEntityB]} ON PAGES FROM [EquityDerivativesCube] CELL PROPERTIES VALUE, FORMATTED_VALUE, BACK_COLOR, FORE_COLOR, FONT_FLAGS", + mdx: "SELECT NON EMPTY Hierarchize(DrilldownLevel([Currency].[Currency].[ALL].[AllMember])) ON ROWS, NON EMPTY [Measures].[pnl.FOREX] ON COLUMNS, {[Booking].[Desk].[ALL].[AllMember].[LegalEntityA], [Booking].[Desk].[ALL].[AllMember].[LegalEntityB]} ON PAGES FROM [EquityDerivativesCube] CELL PROPERTIES VALUE, FORMATTED_VALUE, BACK_COLOR, FORE_COLOR, FONT_FLAGS", contextValues: {}, updateMode: "once", ranges: { @@ -24,7 +23,13 @@ export const legacyComparisonValues = { }, }, configuration: { - featuredValues: {}, + featuredValues: { + locations: { + "[Currency].[Currency].[AllMember].[GBP],[Measures].[pnl.FOREX]": { + title: "Hello World", + }, + }, + }, }, }, containerKey: "featured-values", diff --git a/src/4.3_to_5.0/__test_resources__/legacyKpi.ts b/src/4.3_to_5.0/__test_resources__/legacyKpi.ts index 52dcb4d6..e32e9a9b 100644 --- a/src/4.3_to_5.0/__test_resources__/legacyKpi.ts +++ b/src/4.3_to_5.0/__test_resources__/legacyKpi.ts @@ -29,7 +29,15 @@ export const legacyKpi: LegacyWidgetState = { }, }, configuration: { - featuredValues: {}, + featuredValues: { + locations: { + // "EUR, USD" is not a member of the sandbox, but it is used here to check that the script supports member names including ",". + "[Currency].[Currency].[AllMember].[EUR, USD],[Measures].[contributors.COUNT]": + { + title: "Hello World", + }, + }, + }, }, }, containerKey: "featured-values", diff --git a/src/4.3_to_5.0/getMigratedKpiTitles.test.ts b/src/4.3_to_5.0/getMigratedKpiTitles.test.ts new file mode 100644 index 00000000..f720a443 --- /dev/null +++ b/src/4.3_to_5.0/getMigratedKpiTitles.test.ts @@ -0,0 +1,40 @@ +import { dataModelsForTests } from "@activeviam/data-model-5.0"; +import { getMigratedKpiTitles } from "./getMigratedKpiTitles"; +import { legacyKpi } from "./__test_resources__/legacyKpi"; +import { _getQueryInLegacyWidgetState } from "./_getQueryInLegacyWidgetState"; +import { MdxSelect, parse } from "@activeviam/mdx-5.0"; + +const cube = dataModelsForTests.sandbox.catalogs[0].cubes[0]; + +describe("getMigratedKpiTitles", () => { + it("returns the migrated KPI titles corresponding to the legacy KPI state, ready to be used in Atoti UI 5.0", () => { + const legacyQuery = _getQueryInLegacyWidgetState(legacyKpi); + + if (!legacyQuery || !legacyQuery.mdx) { + throw new Error("Expected the legacy KPI state to contain a query"); + } + + const legacyMdx = parse(legacyQuery.mdx); + const migratedKpiTitles = getMigratedKpiTitles(legacyKpi, { + cube, + legacyMdx, + mapping: { + columns: [], + measures: [{ type: "measure", measureName: "contributors.COUNT" }], + rows: [ + { + type: "hierarchy", + dimensionName: "Currency", + hierarchyName: "Currency", + levelName: "Currency", + }, + ], + }, + }); + expect(migratedKpiTitles).toMatchInlineSnapshot(` + { + "[Measures].[contributors.COUNT],[Currency].[Currency].[AllMember].[EUR, USD]": "Hello World", + } + `); + }); +}); diff --git a/src/4.3_to_5.0/getMigratedKpiTitles.ts b/src/4.3_to_5.0/getMigratedKpiTitles.ts new file mode 100644 index 00000000..11b21ebb --- /dev/null +++ b/src/4.3_to_5.0/getMigratedKpiTitles.ts @@ -0,0 +1,141 @@ +import { + Cube, + DataVisualizationWidgetMapping, + KpiWidgetState, + MdxUnknownCompoundIdentifier, + parse, +} from "@activeviam/activeui-sdk-5.0"; +import { + getSpecificCompoundIdentifier, + quote, + getMeasuresAxisName, + getMeasuresPositionOnAxis, + MdxSelect, +} from "@activeviam/mdx-5.0"; + +interface LegacyKpiTitle { + title: string; + tuple: { [hierarchyUniqueName: string]: string[] }; +} + +/** + * Returns the legacy KPI titles attached to `legacyKpiState`. + */ +function _getLegacyKpiTitles( + // Legacy widget states are not typed. + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + legacyKpiState: any, + { cube }: { cube: Cube }, +): LegacyKpiTitle[] { + const locations = + legacyKpiState?.value?.body?.configuration?.featuredValues?.locations; + + if (!locations) { + return []; + } + + const legacyTitles: LegacyKpiTitle[] = []; + + for (const tupleKey in locations) { + const { title } = locations[tupleKey]; + const tuple: { + [hierarchyUniqueName: string]: string[]; + } = {}; + const memberUniqueNames = tupleKey.split(/,(?![^\[]*\])/); + for (const memberUniqueName of memberUniqueNames) { + const compoundIdentifier = + parse(memberUniqueName); + const specificCompoundIdentifier = getSpecificCompoundIdentifier( + compoundIdentifier, + { cube }, + ); + if (specificCompoundIdentifier.type === "measure") { + tuple[`[Measures].[Measures]`] = [ + specificCompoundIdentifier.measureName, + ]; + } else if (specificCompoundIdentifier.type === "member") { + const hierarchyUniqueName = quote( + specificCompoundIdentifier.dimensionName, + specificCompoundIdentifier.hierarchyName, + ); + tuple[hierarchyUniqueName] = specificCompoundIdentifier.path; + } + } + legacyTitles.push({ title, tuple }); + } + + return legacyTitles; +} + +/** + * Returns the migrated KPI widget titles corresponding to legacyKpiState. + */ +export function getMigratedKpiTitles( + // Legacy widget states are not typed. + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + legacyKpiState: any, + { + legacyMdx, + mapping, + cube, + }: { + legacyMdx: MdxSelect; + mapping: DataVisualizationWidgetMapping; + cube: Cube; + }, +): KpiWidgetState["titles"] { + const legacyTitles = _getLegacyKpiTitles(legacyKpiState, { cube }); + const migratedTitles: KpiWidgetState["titles"] = {}; + + const numberOfColumnFields = mapping.columns?.length ?? 0; + + const measuresAxisName = getMeasuresAxisName(legacyMdx); + const measuresAxis = legacyMdx.axes.find( + (axis) => axis.name === measuresAxisName, + ); + let measuresPositionInTuple = -1; + if (measuresAxis) { + const measuresPositionOnAxis = getMeasuresPositionOnAxis(measuresAxis); + measuresPositionInTuple = ["0", "COLUMNS"].includes(measuresAxisName) + ? measuresPositionOnAxis + : numberOfColumnFields + measuresPositionOnAxis; + } + + // Atoti UI 5.0 KPI widgets expect tuple keys to express members in the following order: + // - column fields first + // - then row fields + const ordinalFields = [...(mapping.columns ?? []), ...(mapping.rows ?? [])]; + + legacyTitles.forEach(({ title, tuple }) => { + const memberUniqueNames: string[] = []; + + ordinalFields.forEach((field) => { + if (field.type === "hierarchy") { + const hierarchyUniqueName = quote( + field.dimensionName, + field.hierarchyName, + ); + const namePath = tuple[hierarchyUniqueName]; + if (namePath) { + memberUniqueNames.push( + `${hierarchyUniqueName}.${quote(...namePath)}`, + ); + } + } + }); + + if (measuresPositionInTuple > -1) { + const measureName = tuple[quote("Measures", "Measures")][0]; + memberUniqueNames.splice( + measuresPositionInTuple, + 0, + quote("Measures", measureName), + ); + } + + const tupleKey = memberUniqueNames.join(","); + migratedTitles[tupleKey] = title; + }); + + return migratedTitles; +} diff --git a/src/4.3_to_5.0/migrateKpi.test.ts b/src/4.3_to_5.0/migrateKpi.test.ts index 412e8938..7519e845 100644 --- a/src/4.3_to_5.0/migrateKpi.test.ts +++ b/src/4.3_to_5.0/migrateKpi.test.ts @@ -39,6 +39,9 @@ describe("migrateKpi", () => { }, ], "serverKey": "my-server", + "titles": { + "[Measures].[contributors.COUNT],[Currency].[Currency].[AllMember].[EUR, USD]": "Hello World", + }, "widgetKey": "kpi", } `); @@ -81,6 +84,9 @@ describe("migrateKpi", () => { }, "queryContext": [], "serverKey": "my-server", + "titles": { + "[Measures].[pnl.FOREX],[Currency].[Currency].[AllMember].[GBP]": "Hello World", + }, "widgetKey": "kpi", } `); diff --git a/src/4.3_to_5.0/migrateKpi.ts b/src/4.3_to_5.0/migrateKpi.ts index a9ebf847..96c799a9 100644 --- a/src/4.3_to_5.0/migrateKpi.ts +++ b/src/4.3_to_5.0/migrateKpi.ts @@ -29,6 +29,8 @@ import { _getQueryInLegacyWidgetState } from "./_getQueryInLegacyWidgetState"; import { _getTargetCubeFromServerUrl } from "./_getTargetCubeFromServerUrl"; import { _migrateQuery } from "./_migrateQuery"; import { produce } from "immer"; +import { getMigratedKpiTitles } from "./getMigratedKpiTitles"; +import { PartialMigrationError } from "../PartialMigrationError"; const moveExpressionToWithClause = ( draft: any, @@ -186,8 +188,10 @@ export function migrateKpi( shouldUpdateFiltersMdx, }); + const { mdx } = query; + const mapping = deriveMappingFromMdx({ - mdx: query.mdx, + mdx, cube, widgetPlugin: pluginWidgetKpi, }); @@ -206,6 +210,30 @@ export function migrateKpi( }), }; + try { + if (legacyMdx) { + // Migrate manually entered KPI titles. + const migratedTitles = getMigratedKpiTitles(legacyKpiState, { + cube, + mapping, + legacyMdx, + }); + if (migratedTitles && Object.keys(migratedTitles).length > 0) { + migratedWidgetState.titles = migratedTitles; + } + } + } catch (error) { + // Migrating the KPI titles is a best effort. + // The migration script should not fail if this part errors. + throw new PartialMigrationError( + `Could not migrate the titles of the featured values widget named "${legacyKpiState.name}"`, + serializeWidgetState(migratedWidgetState), + { + cause: error, + }, + ); + } + const serializedWidgetState = serializeWidgetState(migratedWidgetState); if (isUsingUnsupportedUpdateMode) { diff --git a/src/PartialMigrationError.ts b/src/PartialMigrationError.ts index 6f4400db..5327675e 100644 --- a/src/PartialMigrationError.ts +++ b/src/PartialMigrationError.ts @@ -10,8 +10,9 @@ export class PartialMigrationError extends Error { constructor( message: string, migratedWidgetState: AWidgetState<"serialized">, + options?: ErrorOptions, ) { - super(message); + super(message, options); this.migratedWidgetState = migratedWidgetState; } }