Skip to main content

Migrating to version 1.0

Background

Version 1.0 of the SDK brings support for Additional Products and prepares it for a range of coming features.

The SDK has also been reworked to work more in harmony with frameworks such as React. Frameworks like React build their redraw decisions on values being changed. For objects, this means that it will interpret a new object reference as something new. So what we did is that in addition to our callback methods notifying that data has changed they also provide a new updated reference to the object that has changed. The references are updated up the tree, so if you listen to the top node and hand the new reference over to React, then React will be able to following the path of changed data to update only what needs updating. These changes should not break event driven code that uses the callbacks.

To facilitate all this changes in the SDK were necessary. While this might make you need you to change your integration we hope that the code will in the end be easier to work with.

The short version

A new class named CfgProduct has been created to coordinate fetch and validate product requests to the API. This class will replace the the coordination previously handled by your code.

When you want to show a Product you will now call await CfgProduct.make(...) . You pass on the resulting CfgProduct to what you use of babylon-view, babylon-view-react, web-ui etc.

You need to attach to the listenForChange method in the returned CfgProduct instance to know when any Product Configuration in the Product has changed. That is, someone has clicked something in the configuration tree.

When you are done with your CfgProduct instance call the destroy-method on it.

An example

Based on upgrading our example-app. All our changes are done in the file ProductView.tsx

Instantiating and loading product

Let's start with the product loading:

Old version

useEffect(() => {
let canceled = false;

setLoading(true);

api.getProduct({
lang,
enterprise,
prdCat,
prdCatVersion,
vendor,
priceList,
partNumber,
})
.then((product) => {
if (canceled) {
return;
}

const { productData, rootFeatureRefs, features } = product;

productConfigurationRef.current = new CfgProductConfiguration(
rootFeatureRefs,
features,
productData.partsData.selOptions || []
);

setProductData(productData);
})
.catch((err) => {
if (canceled) {
return;
}

setError(err);
})
.finally(() => {
if (canceled) {
return;
}
setLoading(false);
});

return () => {
canceled = true;
};
}, [api, enterprise, partNumber, prdCat, prdCatVersion, vendor, priceList, setError]);

New version

useEffect(() => {
let canceled = false;

const mainProductLoadingToken = loadingObservable.startChildLoading();
const innerLoadingToken = {};

CfgProduct.make(
api,
lang,
{
enterprise,
prdCat,
prdCatVersion,
vendor,
priceList,
},
partNumber
)
.then((product) => {
if (canceled) {
return;
}

product.listenForChange((n) => setProduct(n.freshRef));

product.listenForLoading((isLoading) =>
loadingObservable.startStopChildLoading(innerLoadingToken, isLoading)
);

setProduct((oldProduct) => {
if (oldProduct !== undefined) {
oldProduct.destroy();
}
return product;
});
})
.catch((err) => {
if (canceled) {
return;
}

setError(err);
})
.finally(() => {
loadingObservable.stopChildLoading(mainProductLoadingToken));
});

return () => {
loadingObservable.stopChildLoading(innerLoadingToken);
canceled = true;
};
}, [
enterprise,
partNumber,
prdCat,
prdCatVersion,
vendor,
priceList,
setError,
api,
loadingObservable,
]);

A lot of things happen here. Not all of them related. Let's take it from the top.

setLoading(true) / setLoading(false) have been replaced with

const mainProductLoadingToken = loadingObservable.startChildLoading();, loadingObservable.stopChildLoading(mainProductLoadingToken);, loadingObservable.startStopChildLoading(innerLoadingToken, isLoading) and loadingObservable.stopChildLoading(innerLoadingToken);

This is a more stable way of handling the loading spinner when there are more parties who may be loading things. The AggregatedLoadingObservable used for loadingObservable keep count on those who say they are loading and will tell anyone who listen that it is loading until all have finished. We'll come back to the loadingObservable instantiation later.

Moving on, we call CfgProduct.make . This is an async function which will give you a CfgProduct instance. Making the server calls is handled inside CfgProduct, so the only thing you need to do is await/then while it does its magic.

The first argument passed to the make-function is the api. For 99% of you this will be enough and you can safely skip over the rest of this paragraph. For the rest of you, what the make function actually require is an object containing functions for getting the product data, and validating product configurations. If you want to implement client side caching (which you are allowed to) (but be very careful if you do it), this is your entry point.

The coming arguments are pretty much the same as before, only the catalogue-arguments are grouped.

In the then-function we check if this product load was cancelled while we were busy letting the CfgProduct fetch data from the server. This could happen if the user changes what options are selected in the configuration before the reply has had time to come back from the server. Side note: If you don't do it like this you should block the GUI when loading. We believe not blocking it gives a better user experience.

Next step:

product.listenForChange((n) => {
setProduct(n.freshRef);
});

This is important. Without it changes will not propagate to be shown in our React components. Here some intricate stuff is going on. Most developers can safely elect not to understand this, but here goes: When we are notified of the change the notification comes with an object containing the property freshRef. This property is a fresh new reference to the same CfgProduct object we are already showing. Essentially it is a new thin shell wrapping exactly the same CfgProduct we already have. Reassigning with setProduct will invoke the React magic to make re-renders happen. The astute reader might now think "don't I have to start listening for changes on this new product object?" Good observation, but no, as the freshRef is only a thin shell on exactly the same object the current listener will continue to work nicely.

For you who do not use React or any other tool that bases its rerenders on reference changes you can probably ignore freshRef altogether. After all, it's still the same old object underneath.

Over to actually assigning the product:

setProduct((oldProduct) => {
if (oldProduct !== undefined) {
oldProduct.destroy();
}
return product;
});

oldProduct is the previous product, we should always call destroy on it to clean up.

setProduct is the setter of a React state-variable:

Before:

const productConfigurationRef = useRef<CfgProductConfiguration>();

const [productData, setProductData] = useState<ProductData | undefined>();
const [unvalidatedSelectedOptions, setUnvalidatedSelectedOptions] = useState<SelectedOption[]>();

After:

const [product, setProduct] = useState<CfgProduct | undefined>();

Look, the confusing stuff above was replaced with one simple state variable! This is the magic of A. letting CfgProduct handle loading, validation, configuration and product data and B. the freshRef we talked about before. This also means that:

Before

const productConfiguration = productConfigurationRef.current;
useEffect(() => {
if (!productConfiguration) {
return;
}
// when the selection changes we save the serialized version to selOptions to trigger
// a validation call (and a rerender) if needed, otherwise we just trigger a rerender
const listener = (notification: ProductConfigurationChangeNotification) => {
if (!notification.validated) {
setUnvalidatedSelectedOptions(productConfiguration.getApiSelection());
}
};

productConfiguration.listenForChange(listener);
return () => {
productConfiguration.stopListenForChange(listener);
};
}, [productConfiguration]);

useEffect(() => {
let canceled = false;

if (unvalidatedSelectedOptions === undefined) {
return;
}

setLoading(true);

api.postValidate(
{
lang,
enterprise,
prdCat,
prdCatVersion,
vendor,
priceList,
partNumber,
},
{ selOptions: unvalidatedSelectedOptions }
)
.then((response) => {
if (canceled) {
return;
}

const { productData, rootFeatureRefs } = response;
setRenderStatus(undefined);
setExportStatus(undefined);
setProductData(productData);

const productConfiguration = productConfigurationRef.current;
if (productConfiguration !== undefined) {
if (rootFeatureRefs) {
productConfiguration.populateFeatures(rootFeatureRefs);
}
productConfiguration.setApiSelection(productData.partsData.selOptions || [], true);
}
})
.catch((err) => {
if (canceled) {
return;
}

setError(err);
})
.finally(() => {
if (canceled) {
return;
}
setUnvalidatedSelectedOptions(undefined);
setLoading(false);
});

return () => {
canceled = true;
};
}, [
api,
enterprise,
unvalidatedSelectedOptions,
partNumber,
prdCat,
prdCatVersion,
setError,
vendor,
priceList,
]);

After:

// Gone. Nothing. Empty. Joy! The happy tingling feeling of less code to maintain!

... and now there is only some minor work left. The heavy lifting has been done, and mostly it was deleting code!

Loading indicator (spinner)

Before:

const [loading, setLoading] = useState(false);
const [loadingModels, setLoadingModels] = useState(false);

After:

const [loading, setLoading] = useState(false);
const loadingObservable = useMemo(() => {
const agg = new AggregatedLoadingObservable();
agg.listen(setLoading);
return agg;
}, []);

const setLoadingModels = useMemo(() => {
const token = {};
return (b: boolean) => loadingObservable.startStopChildLoading(token, b);
}, [loadingObservable]);

The loadingObservable code makes a memoized version of loadingObservable that survive rerenders. The empty dependency list means it will never change. The listen-part will update the loading variable so that spinners and such knows when to show.

The setLoadingModels is some glue-code to get a callback that can be passed along. The useMemo ensures that token that is the key in the loadingObservable is not regenerated.

Export / Render

Before:

const [exportProduct, setExportProduct] = useState<SelectedOption[]>();
const [renderProduct, setRenderProduct] = useState<SelectedOption[]>();

After:

const [exportProduct, setExportProduct] = useState<AdditionalProductConfiguration>();
const [renderProduct, setRenderProduct] = useState<AdditionalProductConfiguration>();

Before:

api.postExport(
{
lang,
enterprise,
prdCat,
prdCatVersion,
vendor,
priceList,
partNumber,
},
{
format: "fbx",
selOptions: exportProduct,
}
)
.then((response) => {

...


targetCameraArgs = {
location: Vector3.TransformCoordinates(position, BABYLON_TO_CET_MATRIX),
target: Vector3.TransformCoordinates(contentPosition, BABYLON_TO_CET_MATRIX),
nearClip: nearClipping,
fov: fov,
};

...

api.postRender(
{
lang,
enterprise,
prdCat,
prdCatVersion,
vendor,
priceList,
partNumber,
},
{
selOptions: renderProduct,
format: "png",
width: Math.floor(width),
height: Math.floor(height),
targetCameraArgs,
}
)
.then((response) => {

After:

XYZ must be manually extracted

Version 4.2 of Babylon.js changed the inner representation of Vector3. You can no longer pass location and target directly, but must extract x, y and z manually.

api.postExport(
{
lang,
enterprise,
prdCat,
prdCatVersion,
vendor,
priceList,
partNumber,
},
{
...exportProduct,
format: "fbx",
}
)
.then((response) => {

...

const location = Vector3.TransformCoordinates(position, BABYLON_TO_CET_MATRIX);
const target = Vector3.TransformCoordinates(contentPosition, BABYLON_TO_CET_MATRIX);
targetCameraArgs = {
location: { x: location.x, y: location.y, z: location.z },
target: { x: target.x, y: target.y, z: target.z },
nearClip: nearClipping,
fov: fov,
};

...

api.postRender(
{
lang,
enterprise,
prdCat,
prdCatVersion,
vendor,
priceList,
partNumber,
},
{
...renderProduct,
format: "png",
width: Math.floor(width),
height: Math.floor(height),
targetCameraArgs,
}
)
.then((response) => {

Before:

function handleExport(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault();
if (productConfiguration === undefined) {
return;
}
setExportProduct(productConfiguration.getApiSelection());
}

function handleRender(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault();
if (productConfiguration === undefined) {
return;
}
setRenderProduct(productConfiguration.getApiSelection());
}

After:

function handleExport(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault();
if (product === undefined) {
return;
}

setExportProduct(product.getApiSelection());
}

function handleRender(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault();
if (product === undefined) {
return;
}

setRenderProduct(product.getApiSelection());
}

These changes are needed to support render and export of Products using Additional Products. These changes makes sure the recursive data needed for this is used.

React DOM

Finally...

Before:

<main>
<PageHeader parentURL={props.parentURL}>Configurator</PageHeader>
{productData === undefined && (
<OverlayLoading fullWindow={true} className={styles.fontSize10} />
)}
<ConfiguratorWrapper>
<CanvasWrapper
ref={wrapperRef}
loading={loading || loadingModels}
className={styles.fontSize10}
>
{productData !== undefined && productConfiguration !== undefined && (
<BabylonView
applicationAreas={applicationAreas}
orbitalCameraConfigurationCallback={(c) => {
orbitalCameraConfigurationRef.current = c;
}}
configuration={viewConfiguration}
errorCallback={setError}
height={height}
loadingCallback={setLoadingModels}
productData={productData}
productConfiguration={productConfiguration}
width={width}
showInspector={inspectorContext?.showInspector}
/>
)}
</CanvasWrapper>
{productData !== undefined && productConfiguration !== undefined && (
<Configurator
exportStatus={exportStatus}
handleExport={api.hasFeature("export") ? handleExport : undefined}
handleRender={api.hasFeature("render") ? handleRender : undefined}
productData={productData}
productConfiguration={productConfiguration}
renderStatus={renderStatus}
/>
)}
</ConfiguratorWrapper>
</main>

After:

<main>
<PageHeader parentURL={props.parentURL}>Configurator</PageHeader>
{product === undefined && <OverlayLoading fullWindow={true} className={styles.fontSize10} />}
<ConfiguratorWrapper>
<CanvasWrapper ref={wrapperRef} loading={loading} className={styles.fontSize10}>
{product !== undefined && (
<BabylonView
applicationAreas={applicationAreas}
orbitalCameraConfigurationCallback={(c) => {
orbitalCameraConfigurationRef.current = c;
}}
configuration={viewConfiguration}
errorCallback={setError}
height={height}
loadingCallback={setLoadingModels}
product={product}
width={width}
showInspector={inspectorContext?.showInspector}
/>
)}
</CanvasWrapper>
{product !== undefined && (
<Configurator
exportStatus={exportStatus}
handleExport={api.hasFeature("export") ? handleExport : undefined}
handleRender={api.hasFeature("render") ? handleRender : undefined}
product={product}
renderStatus={renderStatus}
/>
)}
</ConfiguratorWrapper>
</main>

productData and productConfiguration are replaced with product, and less loading-code. That's pretty much it!

As a side note, the code in this file use then-catch-finally instead of the these days more common async-await. We don't have any strong opinions on which style to use. In this case as the React components aren't async we would have to wrap all in a self evaluating async function ( (async () => { / Do awaity stuff / })() ), so then-catch-finally is a bit cleaner.