Migrate from Atoti UI 4 to 5
Migrating from Atoti UI 4 to 5 includes two parts:
- content migration
- code migration
Content migration
During their time in Atoti UI, our users have saved dashboards, widgets, filters, measures and more. We refer to everything they saved as "content".
Atoti UI 5 is not backward compatible: this content needs to be migrated.
Fortunately, a tool will do it for us. It is named atoti-ui-migration.
Follow along its README. By the end of it, you will have migrated your content.
Code migration
Atoti UI 5 comes with a major shift in technical design. ActiveUI 4 was an object-oriented JavaScript library. Atoti UI 5 on the other hand is an off-the-shelf (yet extensible) application, made with TypeScript and tightly integrated with React. It makes it more performant, more robust and easier to learn - assuming that you already have some experience with React.
Let's scratch the surface and understand what this means, concretely.
You are in one of these 3 scenarios:
- you used Atoti UI
- you used Atoti UI with extensions
- you developed an application using the Atoti JavaScript API
You used Atoti UI
Then you are already done. Conformism has perks sometimes! You can skip everything below 🎉.
You used Atoti UI with extensions
This section is addressed to you if your application contained custom widgets, actions, tools, menus, etc.
As the API of the version 5 is quite different from its predecessor's, the best strategy is to start from scratch.
Download the Atoti UI starter (see setup).
Notice a couple of pleasant surprises:
- it contains a lot fewer files than your previous project did.
- it builds and starts faster.
Let's consider an example to get a feel for how we will be able to migrate these custom extensions. Suppose that your project contains a custom widget plugin allowing users to quickly filter a page on a city, say "Paris".
Brace yourself, lots of code incoming! You can either take your time and read through all of the v4 extension and its migrated v5 version or jump straight down to the analysis and allow yourself to go back up to investigate points of interest.
The v4 extension code looks like this:
- pluginWidgetCustomFilter.tsx
import React, { FC } from "react";
import { ReactContainerComponentProps } from "@activeviam/activeui-sdk";
import { getUpdatedPageFilters } from "./getUpdatedPageFilters";
import { isFilteredOnParis } from "./isFilteredOnParis";
const cubeName = "MyCube";
/**
* Displays a checkbox allowing to quickly filter a dashboard page on Paris.
*/ const CustomFilter: FC<ReactContainerComponentProps> = function (props) {
// @ts-ignore `getParentPage` is not part of the definition types for `props.containerApi`, but it is defined during runtime ¯\_(ツ)_/¯
const pageApi = props.containerApi.getParentPage();
const pageFilters = pageApi.getPageFilters(); // Therefore `pageFilters` is any, unfortunately.
const isChecked = isFilteredOnParis(pageFilters, cubeName);
const handleChange = () => {
const updatedPageFilters = getUpdatedPageFilters(
pageFilters,
isChecked,
cubeName,
);
pageApi.setPageFilters(updatedPageFilters);
};
return (
<div
style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
>
<input
type="checkbox"
id="filter-on-paris"
checked={isChecked}
onChange={handleChange}
/>
<label htmlFor="filter-on-paris">Filter on Paris</label>
</div>
);
};
export const pluginWidgetCustomFilter = {
key: "custom-filter",
staticProperties: { choosableFromUI: true, component: CustomFilter },
};
View v4 util functions
- isFilteredOnParis.ts
/**
* Returns whether `pageFilters` contains a filter on Paris.
*/
export function isFilteredOnParis(
pageFilters: { [cubeName: string]: string[] },
cubeName: string,
) {
return pageFilters[cubeName]?.includes(
"[Geography].[City].[ALL].[AllMember].[Paris]",
);
}
- getUpdatedPageFilters.ts
/**
* Returns an updated `pageFilters`, with a filter on Paris added if `isChecked` is false - or removed otherwise.
* Does not mutate `pageFilters`.
*/
export function getUpdatedPageFilters(
pageFilters: { [cubeName: string]: string[] },
isChecked: boolean,
cubeName: string,
) {
if (isChecked) {
// Remove the filter on Paris.
return {
...pageFilters,
[cubeName]: pageFilters[cubeName].filter(
(mdx) => mdx !== "[Geography].[City].[ALL].[AllMember].[Paris]",
),
};
} else {
// Add a filter on Paris
return {
...pageFilters,
[cubeName]: [
...pageFilters[cubeName],
"[Geography].[City].[ALL].[AllMember].[Paris]",
],
};
}
}
View v4 plugin registration
- en-US.json
"bookmarks": {
"new": {
"custom-filter": {
"title": "Custom filter"
}
}
},
- App.tsx
import { pluginWidgetCustomFilter } from "./pluginWidgetCustomFilter";
const activeUI = createActiveUI({
plugins: {
container: [pluginWidgetCustomFilter],
},
});
And here it is, migrated to v5:
- pluginWidgetCustomFilter.tsx
import React, { FC } from "react";
import {
WidgetPlugin,
WidgetPluginProps,
getCube,
getPage,
isWidgetInDashboard,
useDataModels,
} from "@activeviam/activeui-sdk";
import { getUpdatedDashboardState } from "./getUpdatedDashboardState";
import { isFilterOnParis } from "./isFilterOnParis";
const serverKey = "my-server";
const cubeName = "MyCube";
/**
* Displays a checkbox allowing to quickly filter a dashboard page on Paris.
*/ const CustomFilter: FC<WidgetPluginProps> = function (props) {
const dataModels = useDataModels();
const dataModel = dataModels?.[serverKey];
if (!dataModel) {
return <div>Loading the data model of server "{serverKey}"</div>;
}
const cube = getCube(dataModel, cubeName);
if (!cube) {
return (
<div>
Could not find a cube with the name "{cubeName}" within the data model
of the server "{serverKey}".
</div>
);
}
if (!isWidgetInDashboard(props)) {
return <div>Oops! This widget only works in a dashboard.</div>;
}
const { dashboardState, pageKey } = props;
const pageState = getPage(dashboardState, pageKey);
const pageFilters = pageState?.filters ?? [];
const isChecked = pageFilters?.some((mdx) => isFilterOnParis(mdx, cube));
const handleChange = () => {
const updatedDashboardState = getUpdatedDashboardState(
dashboardState,
pageKey,
isChecked,
cube,
);
props.onDashboardChange(updatedDashboardState);
};
return (
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
}}
>
<input
type="checkbox"
id="filter-on-paris"
checked={isChecked}
onChange={handleChange}
/>
<label htmlFor="filter-on-paris">Filter on Paris</label>
</div>
);
};
export const pluginWidgetCustomFilter: WidgetPlugin = {
key: "custom-filter",
Component: CustomFilter,
initialState: { widgetKey: "custom-filter" },
translations: {
"en-US": { key: "Custom filter", defaultName: "New custom filter" },
},
};
View v5 util functions
- isFilterOnParis.ts
import _ from "lodash";
import { Cube, Mdx, createFilter } from "@activeviam/activeui-sdk";
/**
* Returns whether `mdx` represents a filter on Paris.
*/ export function isFilterOnParis(mdx: Mdx, cube: Cube) {
const filter = createFilter(mdx, cube);
return (
filter.type === "members" &&
_.isEqual(filter.members, [["AllMember", "Paris"]])
);
}
- getUpdatedDashboardState.ts
import { produce } from "immer";
import {
Cube,
DashboardState,
createMdxForFilter,
getPage,
} from "@activeviam/activeui-sdk";
import { isFilterOnParis } from "./isFilterOnParis";
/**
* Returns an updated `dashboardState`, with a page filter on Paris added if `isChecked` is false - or removed otherwise.
* Does not mutate `dashboardState`.
*/ export function getUpdatedDashboardState(
dashboardState: DashboardState,
pageKey: string,
isChecked: boolean,
cube: Cube,
) {
return produce(dashboardState, (draft) => {
const draftPageState = getPage(draft, pageKey);
if (!draftPageState) {
return;
}
if (!draftPageState.filters) {
draftPageState.filters = [];
}
if (isChecked) {
// Remove the filter on Paris.
draftPageState.filters.splice(
draftPageState.filters.findIndex((mdx) => isFilterOnParis(mdx, cube)),
1,
);
} else {
// Add a filter on Paris.
draftPageState.filters.push(
createMdxForFilter(
{
type: "members",
dimensionName: "Geography",
hierarchyName: "City",
members: [["AllMember", "Paris"]],
},
cube,
),
);
}
return draft;
});
}
View v5 plugin registration
- plugins.ts
import { pluginWidgetCustomFilter } from "./pluginWidgetCustomFilter";
const widgetPlugins: Array<WidgetPlugin<any, any>> = [pluginWidgetCustomFilter];
Analysis
Let's dive in.
How to access state?
- const pageApi = props.containerApi.getParentPage();
- const pageFilters = pageApi.getPageFilters();
+ const { dashboardState, pageKey } = props;
+ const pageState = getPage(dashboardState, pageKey);
+ const pageFilters = pageState?.filters ?? [];
- pageApi.setPageFilters(updatedPageFilters);
+ props.onDashboardChange(updatedDashboardState);
The containerApi
object used to let you interact with specific bits of state through getters and setters.
Now you get a dashboardState
prop instead, representing the whole state of the current dashboard. See WidgetPluginProps for more details.
This comes with benefits:
dashboardState
is typed, making it easier to find the parts of it that are relevant to the task at hand, for instance by leveraging the autocompletion feature of your code editor.- its attributes are referentially stable, allowing us to leverage memoization. Here, we could memoize our Component based on
pageState.filters
, as it does not depend on anything else. There is no point in wasting resources to rerender it when the user edits things other than filters, as we know that it will never be visually impacted in this case.
Updating big objects is not always trivial, especially when you pay attention to not mutating them. For this purpose, we recommend using immer. It makes these operations less error-prone, and also nicer to write and to read! It is used in the v5 util functions above.
How to register extensions?
This is visible in the plugin registrations above.
- const activeUI = createActiveUI({
- plugins: {
- container: [pluginWidgetCustomFilter],
- },
- });
+ const widgetPlugins: Array<WidgetPlugin<any, any>> = [pluginWidgetCustomFilter];
You used to pass your extensions to a function called createActiveUI
.
Now, you must add them to a configuration
object in your index.ts
file. This is because Atoti UI 5 is a build with extension points, whereas ActiveUI 4 was a sandbox, that you cloned when starting your project.
This change also comes with benefits:
- your project now only contains your own files.
- it builds and starts faster.
- future migrations will be easier: updates made by the Atoti team to the sources of Atoti UI will be transparent to you, as they should be.
The form of filters
Notice that filters used to be represented as strings, whereas they are now represented as Mdx objects. This is visible in particular in the signature of the util functions. This change allows your extension - and Atoti UI 5 in general - to be more performant.
More possibilities for error handling
Atoti UI 5 comes with exhaustive definition types and typeguards. Below is an example where our v4 code would have thrown an error if someone tried to use our extension outside a dashboard (for instance, in an atoti notebook). After the migration, TypeScript does not allow us to write unsafe code. And typeguards (such as isWidgetInDashboard) make our code more robust.
- // @ts-ignore `getParentPage` is not part of the definition types for `props.containerApi`, but it is defined during runtime ¯\_(ツ)_/¯
- const pageApi = props.containerApi.getParentPage();
+ if (!isWidgetInDashboard(props)) {
+ return <div>Oops! This widget only works in a dashboard.</div>;
+ }
To go further, you can read the Atoti UI tutorial.
You developed an application using the Atoti JavaScript API
The best strategy is to go through your @activeviam/activeui-sdk
imports and replace them by equivalents in the new API.
In particular, note that Container can be replaced by Widget or Dashboard, depending on the use case.
To go further, you can read: integrate Atoti UI Components.