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 tool in the tools panel is called the 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/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 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/activeui-sdk";
+ import { EditorProps, useDataModel, getCube } from "@activeviam/activeui-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 content editor!</div>;
};
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 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/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!
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
.
- Headback to our terminal and navigate to our project.
- 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
.
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/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 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/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]`,
},
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.