Skip to main content

Working with selection

Now lets add some functionality to our new menu item!

We'll use the countries we select on the map to filter other widgets.

First, our menu item needs to know which countries have been selected.

Introduce a selection type#

FilterOnCountriesMenuItem's props receive something called selection. But right now, selection is always undefined:

pluginMenuItemFilterOnCountries.tsx:

const handleClicked = (param) => {- alert("hello world!");+ console.log(props.selection);  props.onClick?.(param);};

This is because Map doesn't pass selection to its menu item plugins… yet.

But before we tackle that… what exactly is selection?

It's actually up to us. It can be anything we choose that meaningfully conveys what pieces of data a user selected.

In our case, let's start by creating a type that shows what our selection will look like.

Let's use the namePath of each country in the Countries hierarchy (see Member). namePath is a good choice because it's easy to read and interpret.

For instance, if we selected France and Argentina, then selection would look like this:

[  ["AllMember", "Europe", "France"],  ["AllMember", "South America", "Argentina"],];

Let's create this type in map.types.ts:

+ export type CountriesSelection = string[][];

Next, we'll tell the map, map plugin, and FilterOnCountriesMenuItem plugin to expect a selection in the shape of CountriesSelection:

Map.tsx:

- import { MapWidgetState } from "./map.types";+ import { CountriesSelection, MapWidgetState } from "./map.types";
...
- export const Map = withQueryResult<MapWidgetState>((props) => {+ export const Map = withQueryResult<MapWidgetState, CountriesSelection>((props) => {

pluginWidgetMap.ts:

  import {-   CellSetSelection,    MdxSelect,    parse,    WidgetPlugin,  } from "@activeviam/activeui-sdk";- import { MapWidgetState } from "./map.types";+ import { CountriesSelection, MapWidgetState } from "./map.types";
...
- export const pluginWidgetMap: WidgetPlugin<MapWidgetState, CellSetSelection> = {+ export const pluginWidgetMap: WidgetPlugin<MapWidgetState, CountriesSelection> = {

pluginMenuItemFilterOnCountries.tsx

- import { MapWidgetState } from "./map.types";+ import { CountriesSelection, MapWidgetState } from "./map.types";
...
- const FilterOnCountriesMenuItem: FC<MenuItemProps<MapWidgetState>> = (+ const FilterOnCountriesMenuItem: FC<MenuItemProps<MapWidgetState, CountriesSelection>> = (
...
- export const pluginMenuItemFilterOnCountries: MenuItemPlugin<MapWidgetState> = {+ export const pluginMenuItemFilterOnCountries: MenuItemPlugin<MapWidgetState, CountriesSelection> = {

Control the selected countries#

Out-of-the-box, Plotly offers some powerful selection tools.

Two such tools are the box select and lasso select. They are circled in green here:

The Plot Component used in Map.tsx let's us know what the user selected via the onSelected prop (See the React Plotly event handler props). Let's use it and control the Plot's selection!

Map.tsx

import React, {  useEffect,  useMemo,  useRef,+ useState,} from "react";
...
export const Map = withQueryResult<MapWidgetState, CountriesSelection>(  (props) => {+   const [selectedIndices, setSelectedIndices] = useState<number[] | undefined>();    const { data, error, isLoading } = props.queryResult;
  ...
+   const handleSelectionChanged = (payload: Plotly.PlotSelectionEvent) => {+     if (payload) {+       const indices = payload.points.map(({ pointIndex }) => pointIndex)+       setSelectedIndices(indices);+     }+   };
    return (      <div>
          ...
          : (          <Plot            data={[              {                type: "choropleth",
                ...
+               selectedpoints: selectedIndices,              },            ]}+           onSelected={handleSelectionChanged}
            ...
          />        )}      </div>    );}

Now that Map can tell us what's been selected, we'll grab the underlying data and transform it into the shape of CountriesSelection.

Map.tsx

- import React, { useRef, useState } from "react";+ import React, { useEffect, useRef, useState } from "react";...
export const Map = withQueryResult<MapWidgetState, CountriesSelection>(  (props) => {    const [selectedIndices, setSelectedIndices] = useState<      number[] | undefined    >();
    const { data, error, isLoading } = props.queryResult;
+   const { onSelectionChange } = props;
+   useEffect(() => {+     if (!data || !onSelectionChange) {+       return;+     }+     const rowsAxis = data.axes[1];+     const selectedCountries: CountriesSelection = (selectedIndices ?? []).map(+       (pointIndex) => {+         const position = rowsAxis.positions[pointIndex];+         // Only one hierarchy (Countries) is chosen on the rows axis of the map's query.+         const member = position[0];+         return member.namePath;+       }+     );++     console.log(selectedCountries);+   }, [data, onSelectionChange, selectedIndices]);}

Nice! We are really getting there!

With one minor tweak, we can expose any changes in selection using onSelectionChange:

Map.tsx

    useEffect(() => {      if (!data || !onSelectionChange) {        return;      }      const rowsAxis = data.axes[1];      const selectedCountries: CountriesSelection = (selectedIndices ?? []).map(        (pointIndex) => {          const position = rowsAxis.positions[pointIndex];          // Only one hierarchy (Countries) is chosen on the rows axis of the map's query.          const member = position[0];          return member.namePath;        }      );-     console.log(selectedCountries);+     onSelectionChange(selectedCountries);    }, [data, onSelectionChange, selectedIndices]);

Filter based on a selection#

Now that we've exposed the selection, we can use it to filter other widgets!

In order to do this, we are going to use props.dashboardState and props.onDashboardChange. Specifically, we will be adding filters to the other widgets' states within the dashboard state.

warning

props.dashboardState and props.onDashboardChange are only accessible inside of a dashboard.

For type-safety, let's add an early return if we are not in a dashboard (e.g. like in atoti):

pluginMenuItemFilterOnCountries.tsx

import {
  ...
+ isActionInDashboard,} from "@activeviam/activeui-sdk"

const FilterOnCountriesMenuItem: FC<MenuItemProps<MapWidgetState, CountriesSelection>> = (  props) => {  const { formatMessage } = useIntl();
+ if (!isActionInDashboard(props)) {+   return null;+ }
  const handleClicked: AntMenuItemProps["onClick"] = (param) => {    console.log(props.selection);    props.onClick?.(param)  };
  ...
};

Some of the functions we're about to use require a Cube (e.g. createFilter and createMdxForFilter).

We can pull the cube off of the data model.

Because data models (and thus their attached cubes) are fetched asynchronously, they can be briefly undefined. To be safe, we need to early return when the cube is still undefined.

pluginMenuItemFilterOnCountries.tsx

import {
  ...
  isActionInDashboard+ getCube,+ useDataModel,} from "@activeviam/activeui-sdk"

const FilterOnCountriesMenuItem: FC<MenuItemProps<MapWidgetState, CountriesSelection>> = (  props) => {  const { formatMessage } = useIntl();+ const dataModel = useDataModel(props.widgetState.serverKey);+ const cube = getCube(dataModel, "Green-growth");
- if (!isActionInDashboard(props)) {+ if (!isActionInDashboard(props) || !cube) {    return null;  }
  ...
};

Now to update dashboardState by adding filters to its widgets! But wait, dashboardState is a complex, deep structure.

How can we safely and reliably make changes to it?

We highly recommend using a library called Immer. Immer will make our code simpler and our updates more performant.

warning

You may be tempted to manually update the state via mutation or deep cloning. However, this will cause a massive performance hit due to how React monitors state to perform rerenders. Trust us, Immer is the way to go here.

To add Immer to our project:

  1. Open your terminal and navigate to our project.
  2. Run:
yarn add immer

When we select some countries and click our menu item, we will add filters for those countries to each widget in the dashboard state.

Let's write this code step by step. The last step will be a recap with the finished code.

First, lets ensure we have a selection to work with. Then we will set up Immer's produce function to start editing the dashboard state.

pluginMenuItemFilterOnCountries.tsx

+ import produce from "immer";
  ...
  const FilterOnCountriesMenuItem: FC<MenuItemProps<MapWidgetState>> = (props) => {
  ...
    if (!isActionInDashboard(props) || !cube) {      return null;    }    const handleClicked: AntMenuItemProps["onClick"] = (param) => {-     console.log(props.selection);+     const { selection } = props;+     if (selection && cube) {+       const updatedDashboardState = produce(props.dashboardState, (draftDashboardState) => {+         // WE WILL FILL THIS IN NEXT.+       });+       props.onDashboardChange(updatedDashboardState);+     }      props.onClick?.(param);    };

Now we'll iterate over all the widgets in the current dashboard page so we can later add filters to each one:

pluginMenuItemFilterOnCountries.tsx

import {  ...+ getPage,} from "@activeviam/activeui-sdk"
  const handleClicked: AntMenuItemProps["onClick"] = (param) => {    const { selection } = props;    if (selection && cube) {      const updatedDashboardState = produce(props.dashboardState, (draftDashboardState) => {-       // WE WILL FILL THIS IN NEXT.+       const pageState = getPage(draftDashboardState, props.pageKey);+       // Iterate over all the widgets in the page.+       for (const leafKey in pageState?.content) {+         // WE WILL FILL THIS IN NEXT.+       }      });      props.onDashboardChange(updatedDashboardState);    }    props.onClick?.(param);  };

However, we have to be careful not to add filters to the map itself. Filtering our map would hide all the non-selected countries.

leafKey can help us differentiate the widgets in a dashboard page. Be careful not to mistake it for widgetKey. They are close, but different:

  • widgetKey (e.g. map, pivot-table, line-chart) defines the type of widget.
  • leafKey (e.g. 1, 2, 3) represents the exact widget in a dashboard page.

For example, two pivot tables would have the same widgetKey, but their own unique leafKey.

Therefore, when we see our map's leafKey, we should make sure to skip it.

pluginMenuItemFilterOnCountries.tsx

  const handleClicked: AntMenuItemProps["onClick"] = (param) => {    const { selection } = props;    if (selection && cube) {      const updatedDashboardState = produce(props.dashboardState, (draftDashboardState) => {        const pageState = getPage(draftDashboardState, props.pageKey);        // Iterate over all the widgets in the page.        for (const leafKey in pageState?.content) {-       // WE WILL FILL THIS IN NEXT.+         // Do not add the filter to our map widget.+         if (leafKey !== props.leafKey) {+           // WE WILL FILL THIS IN NEXT+         }        }      });      props.onDashboardChange(updatedDashboardState);    }    props.onClick?.(param);  };

Adding filters only makes sense when the target widget has a query.

We'll make sure we only apply filters to widgets driven by a query.

pluginMenuItemFilterOnCountries.tsx

import {  ...+ isWidgetWithQueryState,} from "@activeviam/activeui-sdk";

  const handleClicked: AntMenuItemProps["onClick"] = (param) => {    const { selection } = props;    if (selection && cube) {      const updatedDashboardState = produce(props.dashboardState, (draftDashboardState) => {        const pageState = getPage(draftDashboardState, props.pageKey);        // Iterate over all the widgets in the page.        for (const leafKey in pageState?.content) {          // Do not add the filter to our map widget.          if (leafKey !== props.leafKey) {-           // WE WILL FILL THIS IN NEXT+           const widgetState = pageState?.content[leafKey];+           // Only add the filter if the widget has an MDX query.+           if (isWidgetWithQueryState(widgetState)) {+             // WE WILL FILL THIS IN NEXT+           }          }        }      });      props.onDashboardChange(updatedDashboardState);    }    props.onClick?.(param);  };

We are almost there! Now lets add the filters!

If we look at the AWidgetState interface (which all widget states extend), filters is an array of Mdx. In order to create these mdx objects, we can use createMdxForFilter.

pluginMenuItemFilterOnCountries.tsx

import {  ...+ createMdxForFilter,} from "@activeviam/activeui-sdk";

  const handleClicked: AntMenuItemProps["onClick"] = (param) => {    const { selection } = props;    if (selection && cube) {      const updatedDashboardState = produce(props.dashboardState, (draftDashboardState) => {        const pageState = getPage(draftDashboardState, props.pageKey);        // Iterate over all the widgets in the page.        for (const leafKey in pageState?.content) {          // Do not add the filter to our map widget.          if (leafKey !== props.leafKey) {            const widgetState = pageState?.content[leafKey];            // Only add the filter if the widget has an MDX query.            if (isWidgetWithQueryState(widgetState)) {-             // WE WILL FILL THIS IN NEXT+             // Add the filter.+             widgetState.filters = [+               ...(widgetState.filters ?? []),+               createMdxForFilter(+                 {+                   type: "members",+                   dimensionName: "Countries",+                   hierarchyName: "Country",+                   members: selection,+                 },+                 cube+               ),+             ];            }          }        }      });      props.onDashboardChange(updatedDashboardState);    }    props.onClick?.(param);  };

Here is the code recap for easy copy-pasting:

pluginMenuItemFilterOnCountries.tsx

import {
  ...
+ isWidgetWithQueryState,+ createMdxForFilter,+ getPage,} from "@activeviam/activeui-sdk";
+ import produce from "immer"
  const handleClicked: AntMenuItemProps["onClick"] = (param) => {-    console.log(props.selection);+    const { selection } = props;+    if (selection && cube) {+      const updatedDashboardState = produce(props.dashboardState, (draftDashboardState) => {+        const pageState = getPage(draftDashboardState, props.pageKey);         // Iterate over all the widgets in the page.+        for (const leafKey in pageState?.content) {           // Do not add the filter to our map widget.+          if (leafKey !== props.leafKey) {+            const widgetState = pageState?.content[leafKey];             // Only add the filter if the widget has an MDX query.+            if (isWidgetWithQueryState(widgetState)) {              // Add the filter.+             widgetState.filters = [+               ...(widgetState.filters ?? []),+               createMdxForFilter(+                 {+                   type: "members",+                   dimensionName: "Countries",+                   hierarchyName: "Country",+                   members: selection,+                 },+                 cube+               ),+             ];+            }+          }+        }+      });+      props.onDashboardChange(updatedDashboardState);+    }     props.onClick?.(param);  };

It works! …Almost.

To be specific, it only works the first time. After the second time, it appears we have no data:

The cause is simple:

  • On the first go around, we made a filter on Western Europe.
  • On the second, we chained a filter for Northern America.

It's like saying "Give me all of North America in Western Europe," which obviously yields no results.

We can avoid this by removing any existing filter on the Countries hierarchy before adding the next one.

In order to know which hierarchy is targeted by a given filter, we can use createFilter. It allows us to cross the bridge between Filter and Mdx in the opposite direction from what we just did with createMdxForFilter:

import {
  ...
+ createFilter,} from "@activeviam/activeui-sdk";

  const handleClicked: AntMenuItemProps["onClick"] = (param) => {    const { selection } = props;    if (selection && cube) {      const updatedDashboardState = produce(props.dashboardState, (draftDashboardState) => {        const pageState = getPage(draftDashboardState, props.pageKey);        for (const leafKey in pageState?.content) {          if (leafKey !== props.leafKey) {            const widgetState = pageState?.content[leafKey];            if (isWidgetWithQueryState(widgetState)) {              widgetState.filters = [-               ...(widgetState.filters || []),+               ...(widgetState.filters || []).filter((mdx) => {+                 const filter = createFilter(mdx, cube);+                 return filter.dimensionName !== "Countries";+               }),                createMdxForFilter(                  {                    type: "members",                    dimensionName: "Countries",                    hierarchyName: "Country",                    members: selection,                  },                  cube                ),              ];            }          }        }      });      props.onDashboardChange(updatedDashboardState);      props.onClick?.(param);    }  };

Now it works no matter how many time we do it!

Selection improvements#

If we play around with our new map selection, we'll notice a couple unfortunate behaviors:

  • Deselecting countries is impossible.
  • Selecting countries after zooming in throws us back to the initial zoom level.

Let's see how we can improve these.

First, it would be nice if we could deselect all countries by pressing the Escape key. This can be achieved relatively easily:

Map.tsx

import React, {+ KeyboardEvent,  useEffect,  useMemo,  useRef,  useState,} from "react";
export const Map = withQueryResult<MapWidgetState, CountriesSelection>(  (props) => {
    ...
+   const handleKeyUp = (event: KeyboardEvent<HTMLDivElement>) => {+     if (event.key === "Escape") {+       setSelectedIndices(undefined);+     }+   };    return (      <div        ref={container}+       tabIndex={0}+       onKeyUp={handleKeyUp}      >      </div>    );  });

Great!

note

tabIndex is required for the map to be able to listen to keyboard events.

Now let's look at keeping the zoom after a selection event.

The zoom information is stored in the geo attribute of Plot's layout prop.

We have to make sure that Plot does not lose this bit of internal state.

We can achieve this by using a ref to keep track of the zoom information:

Map.tsx


...
export const Map = withQueryResult<MapWidgetState, CountriesSelection>(  (props) => {+   const geoLayoutRef = useRef<Plotly.Layout["geo"] | undefined>({});
    return (      <div>
        ...
         (          <Plot            layout={{+             geo: geoLayoutRef.current,              ...            }}          />        )}      </div>    );}

Reuse existing menu items#

note

This final section does not add more functionality. Rather it achieves the same thing, but with less code.

One thing might be bugging you though… This all works, but we also created some tight coupling.

It seems unfortunate that:

  • We cannot reuse pluginMenuItemFilterOnCountries on other types of widgets.
  • We cannot use existing menu item plugins on our map.

Congrats if this crossed your mind! 🔮

ActiveUI actually provides a stock menu item for this same functionality: pluginMenuItemFilterOnSelection.

pluginMenuItemFilterOnSelection is made to work with any widget representing a CellSet.

To use pluginMenuItemFilterOnSelection, Map must replace our custom CountriesSelection with CellSetSelection.

First, let's delete our selection type in map.types.ts:

- export type CountriesSelection = string[][];

Then we add CellSetSelection back into pluginWidgetMap.ts:

  import {
    ...
+   CellSetSelection,  } from "@activeviam/activeui-sdk";
- import { CountriesSelection, MapWidgetState } from "./map.types";+ import { MapWidgetState } from "./map.types";
...
- export const pluginWidgetMap: WidgetPlugin<MapWidgetState, CountriesSelection> = {+ export const pluginWidgetMap: WidgetPlugin<MapWidgetState, CellSetSelection> = {
};

…we also swap CellSetSelection into Map.tsx:

  import {
    ...
+   CellSetSelection,  } from "@activeviam/activeui-sdk";
- import { CountriesSelection, MapWidgetState } from "./map.types";+ import { MapWidgetState } from "./map.types";
- export const Map = withQueryResult<MapWidgetState, CountriesSelection>(+ export const Map = withQueryResult<MapWidgetState, CellSetSelection>(  (props) => {
  ...
  });

TypeScript throws an error now because we are still passing a selection with our custom shape into onSelectionChange.

Let's change that:

Map.tsx

  import {    ...+   axisIds,  } from "@activeviam/activeui-sdk";
  ...
-   const selectedCountries: CountriesSelection = (selectedIndices ?? []).map(-      (pointIndex) => {+   const selectedCountries: CellSetSelection = {+     axes: [+       {+         id: axisIds.rows,+         positions: (selectedIndices ?? []).map((pointIndex) => {            const position = rowsAxis.positions[pointIndex];            // Only one hierarchy (Countries) is chosen on the rows axis of the map's query.            const member = position[0];-           return member.namePath;-         }-       )+           return [+             {+               dimensionName: "Countries",+               hierarchyName: "Country",+               ...member,+             },+           ];+         })+       }+     ]+   }

The new shape of selection is a bit more complex. It mirrors the CellSet interface.

This allows us to describe every possible selection users can make in a widget.

And now the payoff! You can delete the pluginMenuItemFilterOnCountries.tsx file.

Then in plugins.tsx, replace our old custom key with the key for the stock menu item:

- import { pluginMenuItemFilterOnCountries } from "./training/pluginMenuItemFilterOnCountries";
- pluginWidgetMap.contextMenuItems = ["filter-on-countries"];+ pluginWidgetMap.contextMenuItems = [pluginMenuItemFilterOnSelection.key];
  const menuItemPlugins: MenuItemPlugin<any, any>[] = [-   pluginMenuItemFilterOnCountries,  ];

It's working! And with this implementation, we didn't have to develop a custom menu item!