Working with selection
Now let's 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
useFilterOnCountriesMenuItem
receives a props.selection
. But at the moment, it seems to always be undefined:
pluginMenuItemFilterOnCountries.tsx:
- import { MenuItemPlugin } from "@activeviam/atoti-ui-sdk";
+ import { MenuItemPlugin, MenuItemProps } from "@activeviam/atoti-ui-sdk";
- function useFilterOnCountriesMenuItem() {
+ function useFilterOnCountriesMenuItem(props: MenuItemProps<MapWidgetState>) {
...
const handleClick = () => {
- alert("hello world!");
+ console.log(props.selection);
};
...
}
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/atoti-ui-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";
- function useFilterOnCountriesMenuItem(props: MenuItemProps<MapWidgetState>) {
+ function useFilterOnCountriesMenuItem(props: 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
lets 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.
props.dashboardState
and props.onDashboardChange
are only accessible inside of a dashboard.
For type-safety, let's add an early return if our widget is used outside of a dashboard (in a notebook for instance):
pluginMenuItemFilterOnCountries.tsx
import {
...
+ isActionInDashboard,
} from "@activeviam/atoti-ui-sdk"
function useFilterOnCountriesMenuItem(props: MenuItemProps<MapWidgetState, CountriesSelection>) {
const { formatMessage } = useIntl();
+ if (!isActionInDashboard(props)) {
+ return null;
+ }
const handleClick = () => {
console.log(props.selection);
};
return {
onClick: handleClick,
label: formatMessage({
id: "aui.plugins.menu-item.filter-on-countries.caption",
}),
};
}
...
};
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.
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:
- Open your terminal and navigate to our project.
- Run:
npm 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";
...
function useFilterOnCountriesMenuItem(props: MenuItemProps<MapWidgetState, CountriesSelection>) {
...
if (!isActionInDashboard(props)) {
return null;
}
const handleClicked = () => {
- console.log(props.selection);
+ const { selection } = props;
+ if (selection) {
+ const updatedDashboardState = produce(props.dashboardState, (draftDashboardState) => {
+ // WE WILL FILL THIS IN NEXT.
+ });
+ props.onDashboardChange(updatedDashboardState);
+ }
};
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/atoti-ui-sdk"
const handleClicked = () => {
const { selection } = props;
if (selection) {
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);
}
};
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 = () => {
const { selection } = props;
if (selection) {
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);
}
};
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/atoti-ui-sdk";
const handleClicked = () => {
const { selection } = props;
if (selection) {
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);
}
};
We are almost there! Now lets add the filters!
pluginMenuItemFilterOnCountries.tsx
const handleClicked = () => {
const { selection } = props;
if (selection) {
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 ?? []),
+ {
+ type: "members",
+ dimensionName: "Countries",
+ hierarchyName: "Country",
+ members: selection,
+ },
+ ];
}
}
}
});
props.onDashboardChange(updatedDashboardState);
}
};
Here is the code recap for easy copy-pasting:
pluginMenuItemFilterOnCountries.tsx
import {
...
+ isWidgetWithQueryState,
+ getPage,
} from "@activeviam/atoti-ui-sdk";
+ import produce from "immer"
const handleClicked = () => {
- console.log(props.selection);
+ const { selection } = props;
+ if (selection) {
+ 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 ?? []),
+ {
+ type: "members",
+ dimensionName: "Countries",
+ hierarchyName: "Country",
+ members: selection,
+ },
+ ];
+ }
+ }
+ }
+ });
+ props.onDashboardChange(updatedDashboardState);
+ }
};
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.
import {
...
+ createFilter,
} from "@activeviam/atoti-ui-sdk";
const handleClicked = () => {
const { selection } = props;
if (selection) {
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((filter) => {
+ return filter.dimensionName !== "Countries";
+ }),
{
type: "members",
dimensionName: "Countries",
hierarchyName: "Country",
members: selection,
},
];
}
}
}
});
props.onDashboardChange(updatedDashboardState);
}
};
Now it works no matter how many times 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!
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>
);
}
Everything works fine now !
Reuse existing menu items
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! 🔮
The Atoti JavaScript API 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/atoti-ui-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/atoti-ui-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/atoti-ui-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!