Skip to main content

Selecting a measure

In the previous section, we displayed a map representing the GDP per capita for each country.

This is great! But it could be even better…

GDP is just one of the available measures in our cube. Our map would be much more powerful if we could choose which measure is represented.

And that is exactly what we will do here!

Fields

The far left tab in the tools panel is called Fields. It is the main place users edit their dashboards.

Each widget can define its fields, defined within a contentEditor component. Let's make one for our map.

First, create a file named MapContentEditor.tsx next to pluginWidgetMap.ts, with the following content:

import React, { FC } from "react";

import { EditorProps } from "@activeviam/atoti-ui-sdk";

export const MapContentEditor: FC<EditorProps> = (props) => {
return <div>I am the map's fields!</div>;
};

…and wire it into pluginWidgetMap.ts:

+ import { MapContentEditor } from "./MapContentEditor";


export const pluginWidgetMap: WidgetPlugin = {
Component: Map,
+ contentEditor: MapContentEditor,

...

};

It will look like this when you're done:

Display available measures

In order to choose a new measure, users first need to see which ones are available to them.

Measures exist on the data model exposed by our server.

So let's grab them from there!

Fortunately, the Atoti JavaScript API contains a hook for easily retrieving the data model: useDataModel.

All we need to provide it is the serverKey for the server we are targetting. Since we only have one server, we just use the serverKey from the previous steps: "my-server"

MapContentEditor.tsx


...

- import { EditorProps } from "@activeviam/atoti-ui-sdk";
+ import { EditorProps, useDataModel, getCube } from "@activeviam/atoti-ui-sdk";


...

export const MapContentEditor: FC<EditorProps> = (props) => {
+ const dataModel = useDataModel("my-server");
+ const measures = dataModel ? Object.values(getCube(dataModel, "Green-growth").measures) : [];


return <div>I am the map's fields!</div>;
};
note

The data model is fetched asynchronously while the application loads. Therefore, it can be undefined for a brief moment. This is why we run a ternary on dataModel when defining measures.

In navigating to the measures within the data model, we made a few assumptions about the data model's structure…

We took the first catalog and the first cube. This is because we know that there is only one server, which has one catalog, which has one cube. However, for more complex projects, the Fields tab should allow the user to choose a server and a cube. (However, there will usually only be one catalog per server.)

Great, we have our measures!

How should we display them?

We will use the Tree Component offered by the Atoti JavaScript API.

It might seem counter-intuitive at first glance, because these measures are a flat array whereas Tree seems like it would be more suited for nested structures. But Tree offers some big advantages:

  • It gives us a search without any extra work.
  • If we decide to use measure folders later on, then representing them will be easy.

Let's see how we can integrate it:


...

- import { EditorProps, useDataModel } from "@activeviam/atoti-ui-sdk";
+ import { EditorProps, Tree, useDataModel } from "@activeviam/atoti-ui-sdk";


...

- return <div>I am the map's fields!</div>;
+ return (
+ <Tree
+ isSearchVisible={true}
+ searchPlaceholder="Search measures"
+ value={measures}
+ />
+ );

Voila!

And we even got a functional search!

Internationalization

Did you notice anything naughty in the code we wrote?

That's right: we hardcoded a caption that appears on screen: "Search measures". 🙈 We should always use a translation when adding a caption. It's better to develop this good habit now versus struggling to find and fix all the hardcoded captions when we need to support a different language.

Let's introduce a new translation in pluginWidgetMap.ts:

export const pluginWidgetMap: WidgetPlugin<ChoroplethMapState> = {
Component: Map,


...


translations: {
"en-US": {
key: "Choropleth map",
defaultName: "New map",
+ searchMeasures: "Search measures"
},
},
};

Since we didn't do it in the project setup, we will need to install the dependency required to leverage translations: react-intl.

  1. Headback to our terminal and navigate to our project.
  2. Run:
npm add react-intl

Now we can use our new translation in MapContentEditor.tsx using useIntl's formatMessage function.


...


+ import { useIntl } from "react-intl";


export const MapContentEditor: FC<EditorProps> = (props) => {
+ const { formatMessage } = useIntl();


...


return (
<Tree
isSearchVisible={true}
- searchPlaceholder="Search measures"
+ searchPlaceholder={formatMessage({id: "aui.plugins.widget.map.searchMeasures"})}
value={measures}
/>
);
};

Nothing changed on screen, but if one day we need to support another locale, we will only have to add its translations to the translations object in pluginWidgetMap.ts.

note

This is not specific to fields: useIntl and formatMessage will work in all your components.

Handle the selection of a measure

Now that we have all the measures displayed, we need to know when the user clicks on one of them. To start, let's make a function in MapContentEditor.tsx to handle this:

import {
Tree,
EditorProps,
useDataModel,
+ Measure,
} from "@activeviam/atoti-ui-sdk";


export const MapContentEditor: FC<EditorProps> = (props) => {

...


+ const handleMeasureClicked = (measure: Measure) => {
+ console.log(`We want to switch to ${measure.name}!`)
+ };


return (
<Tree
isSearchVisible={true}
+ onClick={handleMeasureClicked}
searchPlaceholder="Search measures"
value={measures}
/>
);
};

But now comes a tougher problem: how do we update the query with this new measure? We're not even at the right place! Remember: the query is run by useQueryResult, which we called in Map.tsx.

One word…

widgetState

widgetState is one of the props present on WidgetPluginProps and EditorProps. As the name suggests, it is meant to hold the state of the widget (in this case: our map).

Widget state has a few key features:

  • What it contains is up to us.
  • Every update of widgetState is undoable by pressing Ctrl + Z (or Cmd + Z on Mac).
  • widgetState is persisted when users save an instance of this widget, or a dashboard containing it.
  • widgetState is accessible and editable by the the Component itself, as well as the editors and other plugins attached to it (such as menu items).

Take a small break and consider this. Isn't it great?

  • The ability to quickly undo a measure change seems handy.
  • Saving the map and the selected measure is a must have.
  • We can access and update the query from the fields!

Before getting into the implementation, let's write some types.

Let's create a new file along side Map.tsx and call it map.types.ts. It will contain the interface representing the widgetState of our map.

For now, it will just include mdx:

// map.types.ts
import { AWidgetState, MdxString } from "@activeviam/atoti-ui-sdk";

export interface MapWidgetState extends AWidgetState {
mdx: MdxString;
}

Let's take a closer look at this code.

MapWidgetState extends AWidgetState. This is because all WidgetStates are expected to have a certain set of key properties, so we have to use AWidgetState as a foundation.

On top of our foundation, we chose to only add an mdx property.

Note that there are 2 ways of representing an Mdx query: an MdxString or an Mdx object. We will explore their differences and usefulness below, but for now we chose to use an MdxString.

Now let's wire our new MapWidgetState interface to where it's needed:

  • Map.tsx
+ import { MapWidgetState } from "./map.types";


- export const Map: FC<WidgetPluginProps> = (props) => {
+ export const Map: FC<WidgetPluginProps<MapWidgetState>> = (props) => {
  • MapContentEditor.tsx
+ import { MapWidgetState } from "./map.types";


- export const MapContentEditor: FC<EditorProps> = (props) => {
+ export const MapContentEditor: FC<EditorProps<MapWidgetState>> = (props) => {
  • pluginWidgetMap.ts
+ import { MapWidgetState } from "./map.types";


- export const pluginWidgetMap: WidgetPlugin = {
+ export const pluginWidgetMap: WidgetPlugin<MapWidgetState> = {

…Next we have to remove hardcoding our query's MDX in Map.tsx:

  • Map.tsx
...

export const Map: FC<WidgetPluginProps<MapWidgetState>> = (props) => {


...

const { data, error, isLoading } = useQueryResult({
serverKey: "my-server",
queryId: props.queryId,
- query: {
- mdx: `SELECT
- Crossjoin(
- [Green-growth].[Year].[Year].Members,
- [Measures].[Real GDP per capita (USD).MEAN]
- ) ON COLUMNS,
- [Countries].[Country].[Country_Name].Members ON ROWS
- FROM [Green-growth]`,
- },
+ query: {
+ mdx: props.widgetState.mdx
+ }
});

And add the MDX to the widget's initial state in pluginWidgetMap.ts:

  • pluginWidgetMap.ts

initialState: {
widgetKey,
+ mdx: `SELECT
+ Crossjoin(
+ [Green-growth].[Year].[Year].Members,
+ [Measures].[Real GDP per capita (USD).MEAN]
+ ) ON COLUMNS,
+ [Countries].[Country].[Country_Name].Members ON ROWS
+ FROM [Green-growth]`,
},
warning

If you copy/pasted the above code, don't forget to remove the +'s from the mdx.

This might look like a lot of changes to get the same old map, but hang tight. We are just about ready to collect the sweet fruits of our labor.

onWidgetChange

In addition to widgetState, our Fields tab's props also contain an onWidgetChange function that allows us to update the widgetState.

BINGO!

Now we can use onWidgetChange to update our query by supplying it with a new widgetState that has an updated query!

For instance, we could search for the part of our query that looks like [Measures].[Something] and replace that "Something" by the name of the new measure which the user just clicked:

Let's do this in MapContentEditor.tsx:

const handleMeasureClicked = (measure: Measure) => {
- console.log(`We want to switch to ${measure.name}!`)
+ const mdx = props.widgetState.mdx;
+ const newMdx = mdx.replace(
+ /\[Measures\]\.\[.*\]/,
+ `[Measures].[${measure.name}]`
+ );


+ props.onWidgetChange({
+ ...props.widgetState,
+ mdx: newMdx,
+ });
};

It works!

…kind of.

Editing a query using regular expressions opens the door to A LOT of bugs. For instance this won't work if the query mentions any other mesasures, which happens fairly frequently:

  • if the widget gets sorted or filtered
  • if the user chooses other measures or levels
  • if a member gets drilled down

Queries take way too many forms to be editable through regular expressions.

Fortunately, there is a reliable way.

Edit a query (the robust way)

The Atoti JavaScript API provides functions that allow us to extract information from queries and interact with them.

Here are some examples of functions that deal with measures:

We have a problem though… they all require an MdxSelect argument which is a type of Mdx object. Unfortunately, we only have an MdxString.

But we can transform our MdxString into an Mdx object…

Two other functions will help us do this: parse and stringify.

  • parse allows us to convert an MdxString into an Mdx object (assuming that the string is a syntaxically valid piece of MDX). In our case, the query of the map is an MdxSelect type because it starts with the word "SELECT".
  • You guessed it: stringify does the opposite.

Let's put it in practice in MapContentEditor.tsx:

import {
Tree,
EditorProps,
useDataModel,
getCube,
Measure,
+ MdxSelect,
+ parse,
+ getMeasureNames,
+ removeMeasure,
+ addMeasure,
+ stringify,
} from "@activeviam/atoti-ui-sdk";

export const MapContentEditor: FC<EditorProps<MapWidgetState>> = (props) => {
- const measures = dataModel ? getCube(dataModel, "Green-growth").measures : [];
+ const cube = dataModel ? getCube(dataModel, "Green-growth") : null;
+ const measures = cube ? Object.values(cube.measures) : [];


const handleMeasureClicked = (measure: Measure) => {
- const mdx = props.widgetState.mdx;
- const newMdx = mdx.replace(
- /\[Measures\]\.\[.*\]/,
- `[Measures].[${measure.name}]`
- );
+ if (!cube) {
+ // This should normally not happen.
+ // But if it does, then abort mission.
+ return;
+ }


+ const mdx = props.widgetState.mdx;
+ const parsedMdx = parse<MdxSelect>(mdx);
+ const currentMeasureName = getMeasureNames(parsedMdx)[0];
+ const parsedMdxWithoutCurrentMeasure = removeMeasure(parsedMdx, {
+ cube,
+ measureName: currentMeasureName,
+ });
+ const parsedMdxWithNewMeasure = addMeasure(parsedMdxWithoutCurrentMeasure, {
+ cube,
+ measureName: measure.name,
+ });
+ const newMdx = stringify(parsedMdxWithNewMeasure);


props.onWidgetChange({
...props.widgetState,
mdx: newMdx,
});
};

...
}

This does the job too, and it won't break even under the most complex of queries 💪😌

If you play around with our new fields, you will notice a couple things we could improve on:

  1. While clicking on a measure updates our map, there is no indication of which measure is selected.
  2. Clicking on some measures results in an empty map. This is because those measures don't have any data for the year 2019.

We will tackle the selected measure style here and now.

The next section in this tutorial will take care of issues around measures without data.

Indicate which measure is selected

In order to help the user figure out which measure is currently selected, we need a way to make the selected measure look different in the fields.

Let's leverage the Tree node's isDisabled property to give the selected measure a grey color and make it unclickable.

Try the following code, in MapContentEditor.tsx:

- const measures = cube ? cube.measures : [];
+ const measures = (cube ? Object.values(cube.measures) : []).map((measure) => ({
+ ...measure,
+ isDisabled: true,
+ }));


- const handleMeasureClicked = (measure: Measure) => {
+ const handleMeasureClicked = (measure: Measure & { isDisabled: boolean }) => {

Notice how all measures appear disabled in the tree:

If we could set only the currently selected measure as isDisabled, then the job would be done!

Looking back at our implementation of handleMeasureClicked, we can do something similar:

+ const mdx = props.widgetState.mdx;
+ const parsedMdx = parse<MdxSelect>(mdx);
+ const currentMeasureName = getMeasureNames(parsedMdx)[0];


const measures = (cube ? cube.measures : []).map((measure) => ({
...measure,
- isDisabled: true,
+ isDisabled: measure.name === currentMeasureName,
}));


const handleMeasureClicked = (measure: Measure & { isDisabled: boolean }) => {
if (!cube) {
// This should normally not happen.
// But if it does, then abort mission.
return;
}


- const mdx = props.widgetState.mdx;
- const parsedMdx = parse<MdxSelect>(mdx);
- const currentMeasureName = getMeasureNames(parsedMdx)[0];
const parsedMdxWithoutMeasure = removeMeasure(parsedMdx, {
cube,
measureName: currentMeasureName,
});
const parsedMdxWithNewMeasure = addMeasure(parsedMdxWithoutMeasure, {
cube,
measureName: measure.name,
});
const newMdx = stringify(parsedMdxWithNewMeasure);

const newWidgetState = {
...props.widgetState,
name: measure.name,
mdx: newMdx,
};

props.onWidgetChange(newWidgetState);
};

We did it!

Just one last thing though before we call this a day…

Make sure to pay attention here.

This last topic is a BIG contributor towards our application being fast, smooth, and enjoyable. If we get this wrong it will be slow, choppy, and frustrating.

Parsing and performance

Let's focus on this line we wrote in MapContentEditor.tsx:

const parsedMdx = parse<MdxSelect>(mdx);

Remember that we have 2 semantically equivalent objects to represent an Mdx query:

  1. MdxString
  2. Mdx

We can convert one to the other using:

MdxString is nice because it is human readable, but it is not robust enough to edit directly.

If you look at MapContentEditor.tsx, we have to parse the MdxString to extract the currently selected measure's name. This is typical: we always require Mdx in order to edit queries and display their information.

However, we end up running parse each time the Component rerenders.

The bad news: parse is about 3x slower than stringify. It often ends up taking more than 20ms. On standard 60Hz monitors, browsers generate a new frame every 16.7ms.

The point is: calling parse lowers our users' frame rate which in turns gives them a slow, choppy, and frustrating experience.

We can easily avoid this lag if we store what parse returns so we don't have to run parse every time. Let's use widgetState to store the parsed Mdx.

Since we previously stored an MdxString on the widgetState, we will first update the MapWidgetState type to store Mdx instead.

In map.types.ts:

- import { AWidgetState, MdxString } from "@activeviam/atoti-ui-sdk";
+ import { AWidgetState, MdxSelect, Query } from "@activeviam/atoti-ui-sdk";


export interface MapWidgetState extends AWidgetState {
- mdx: MdxString;
+ query: Query<MdxSelect>;
}
note

In the snippet above, Query accepts a type parameter. Here, we are giving it an MdxSelect, because our query starts with the word SELECT. Just for reference, MdxDrillthrough is another type of query that exists.

Notice that we switched from storing only mdx to the whole query. This allows Atoti UI to leverage a few great functions:

These are used when we save and load widgets and dashboards.

Essentially, these functions look for the query attribute of widgets and either:

  1. Convert the Mdx object to an MdxString when saving to the Content Server.
  2. Convert the MdxString to an Mdx object when loading into the browser.

The widget's initial state now expects a query property that contains an Mdx object. Lets parse our MdxString into Mdx and add it to the state.

In pluginWidgetMap.ts

-  import { WidgetPlugin } from "@activeviam/atoti-ui-sdk";
+ import { MdxSelect, parse, WidgetPlugin } from "@activeviam/atoti-ui-sdk";

export const pluginWidgetMap: WidgetPlugin<MapWidgetState> = {
Component: Map,
contentEditor: MapContentEditor,
Icon: IconWorld,
initialState: {
widgetKey,
- mdx: `SELECT
- Crossjoin(
- [Green-growth].[Year].[Year].Members,
- [Measures].[Real GDP per capita (USD).MEAN]
- ) ON COLUMNS,
- [Countries].[Country].[Country_Name].Members ON ROWS
- FROM [Green-growth]`,
+ query:{
+ mdx: parse<MdxSelect>(`SELECT
+ Crossjoin(
+ [Green-growth].[Year].[Year].Members,
+ [Measures].[Real GDP per capita (USD).MEAN]
+ ) ON COLUMNS,
+ [Countries].[Country].[Country_Name].Members ON ROWS
+ FROM [Green-growth]`),
+ }
},

Because we can now pull mdx directly off the widgetState, let's remove the parsing in the Fields tab.

MapContentEditor.tsx:

  import {
EditorProps,
Measure,
Tree,
useDataModel,
getCube,
- MdxSelect,
- parse,
getMeasures,
removeMeasure,
addMeasure,
- stringify,
} from "@activeviam/atoti-ui-sdk";


...

export const MapContentEditor: FC<EditorProps<MapWidgetState>> = (props) => {


...

- const mdx = props.widgetState.mdx;
- const parsedMdx = parse<MdxSelect>(mdx);
- const currentMeasureName = getMeasureNames(parsedMdx)[0];
+ const { mdx } = props.widgetState.query;
+ const currentMeasureName = getMeasureNames(mdx)[0];


...

- const parsedMdxWithoutCurrentMeasure = removeMeasure(parsedMdx, {
+ const parsedMdxWithoutCurrentMeasure = removeMeasure(mdx, {
cube,
measureName: currentMeasureName,
});


const parsedMdxWithNewMeasure = addMeasure(parsedMdxWithoutCurrentMeasure, {
cube,
measureName: measure.name,
});


- const newMdx = stringify(parsedMdxWithNewMeasure);

props.onWidgetChange({
...props.widgetState,
- mdx: newMdx,
+ query: {
+ mdx: parsedMdxWithNewMeasure,
+ },
});

Since useQueryResult requires an MdxString, we can stringify our parsed Mdx before passing it into useQueryResult.

Map.tsx:

- import React, { FC, useRef } from "react";
+ import React, { FC, useMemo, useRef } from "react";


import {
useQueryResult,
WidgetPluginProps,
CellSet,
+ stringify,
} from "@activeviam/atoti-ui-sdk";


...

export const Map: FC<WidgetPluginProps<MapWidgetState>> = (props) => {

...

+ const { mdx } = props.widgetState.query;
+ const stringifiedMdx = useMemo(() => stringify(mdx), [mdx]);

const { data, error, isLoading } = useQueryResult({
serverKey: "my-server",
queryId: props.queryId,
query: {
- mdx: props.widgetState.mdx
+ mdx: stringifiedMdx,
},
});

Great job! This section was difficult, but we did it!

Our users will now have a smooth experience with their maps and tools!

And the really good news: the remaining sections of the tutorial are all easier! 😁