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!
#
Content editorThe top drawer in the left bar is called the content editor. It is the main place users edit their dashboards.
Each widget can define its own content editor. 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/activeui-sdk";
export const MapContentEditor: FC<EditorProps> = (props) => { return <div>I am the map's content editor!</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 measuresIn 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, ActiveUI has 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 previouse steps: "my-server"
MapContentEditor.tsx
...
- import { EditorProps } from "@activeviam/activeui-sdk";+ import { EditorProps, useDataModel } from "@activeviam/activeui-sdk";
...
export const MapContentEditor: FC<EditorProps> = (props) => {+ const dataModel = useDataModel("my-server");+ const measures = dataModel ? dataModel.catalogs[0].cubes[0].measures : [];
return <div>I am the map's content editor!</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, content editors 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 ActiveUI.
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/activeui-sdk";+ import { EditorProps, Tree, useDataModel } from "@activeviam/activeui-sdk";
...
- return <div>I am the map's content editor!</div>;+ return (+ <Tree+ isSearchVisible={true}+ searchPlaceholder="Search measures"+ value={measures}+ />+ );
Voila!
And we even got a functional search!
#
InternationalizationDid 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<ChloroplethMapState> = { Component: Map,
...
translations: { "en-US": { key: "Chloropleth map", defaultName: "New map",+ searchMeasures: "Search measures" }, },};
Since we didn't do it in the project setup, we will need to install the dependency ActiveUI relies on to translate: react-intl
.
- Headback to our terminal and navigate to our project.
- Run:
yarn 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 content editors: useIntl
and formatMessage
will work in all your components.
#
Handle the selection of a measureNow that we have all the measures displayed, we need to know when the user clicks on one of them. To start, lets make a function in MapContentEditor.tsx
to handle this:
import { Tree, EditorProps, useDataModel,+ Measure,} from "@activeviam/activeui-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 content editor!
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.tsimport { AWidgetState, MdxString } from "@activeviam/activeui-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 content editor'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 expanded
Queries take way too many forms to be editable through regular expressions.
Fortunately, there is a reliable way.
#
Edit a query (the robust way)ActiveUI provides functions that allow us to extract information from queries and interact with them.
Here are some examples of functions that deal with measures:
- getMeasures
- removeMeasure
- addMeasure (This one will be particularly interesting).
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, Measure,+ MdxSelect,+ parse,+ getMeasures,+ removeMeasure,+ addMeasure,+ stringify,} from "@activeviam/activeui-sdk";
export const MapContentEditor: FC<EditorProps<MapWidgetState>> = (props) => {- const measures = dataModel ? dataModel.catalogs[0].cubes[0].measures : [];+ const cube = dataModel ? dataModel.catalogs[0].cubes[0] : null;+ const measures = cube ? 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 = getMeasures(parsedMdx)[0].measureName;+ 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 content editor, you will notice a couple things we could improve on:
- While clicking on a measure updates our map, there is no indication of which measure is selected.
- 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 selectedIn 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 content editor.
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 ? 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 = getMeasures(parsedMdx)[0].measureName;
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 measures = getMeasures(parsedMdx)[0].measureName; 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 performanceLet'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:
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/activeui-sdk";+ import { AWidgetState, MdxSelect, Query } from "@activeviam/activeui-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 ActiveUI 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:
- Convert the
Mdx
object to anMdxString
when saving to the Content Server. - Convert the
MdxString
to anMdx
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/activeui-sdk";+ import { MdxSelect, parse, WidgetPlugin } from "@activeviam/activeui-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
, lets remove the parsing in the content editor.
MapContentEditor.tsx
:
import { EditorProps, Measure, Tree, useDataModel,- MdxSelect,- parse, getMeasures, removeMeasure, addMeasure,- stringify, } from "@activeviam/activeui-sdk";
...
export const MapContentEditor: FC<EditorProps<MapWidgetState>> = (props) => {
...
- const mdx = props.widgetState.mdx;- const parsedMdx = parse<MdxSelect>(mdx);- const currentMeasureName = getMeasures(parsedMdx)[0].measureName;+ const { mdx } = props.widgetState.query;+ const currentMeasureName = getMeasures(mdx)[0].measureName;
...
- 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/activeui-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 editors!
And the really good news: the remaining sections of the tutorial are all easier! 😁