Skip to content

Extending a template to customize your app

In this tutorial, we shall extend our basic template based iTwin Studio app by adding a frontstage, along with an iModel selector component. All of the functionality that we will go through in this app is already available in the viewer template.

  1. Follow the steps in the Getting Started section guide to create a new iTwin Studio app with the basic template:

  2. Enter a name for your app when prompted.

  3. Select “Basic” as the app type.

  1. From within the terminal, navigate to the child directory that is named after your app (cd ./my-app-name).
  2. Execute pnpm install.
  1. Open your app in VS Code and launch the Debug in Studio debugger configuration.
  2. This will open your installed iTwin Studio with your application in context and will run your application in dev mode.
  3. Note that the app simply logs “Hello world!”. Let’s change that.

Add a frontstage with an iModel Selector component

Section titled “Add a frontstage with an iModel Selector component”
  1. Create a directory within the frontend directory named frontstages
  2. Add a file named Selection.tsx in the frontstages directory.
  3. Add the following code in Selection.tsx:
import React, { type ReactElement } from "react";
import { FrontstageUtilities, StageUsage } from "@itwin/appui-react";
import { Logger } from "@itwin/core-bentley";
import { IModelApp } from "@itwin/core-frontend";
import { type IModelFull, IModelGrid, type IModelGridProps } from "@itwin/imodel-browser-react";
import { ProgressRadial, Text } from "@itwin/itwinui-react";
import { StudioApp } from "@bentley/studio-apps-frontend-api";
import { StudioStartupApp } from "@bentley/studio-startup-apps-frontend-api";
import { LOCALE_NAMESPACE, LOG_CATEGORY } from "../../common/Constants";
// import { frontstageId as viewerFrontstageId } from "./Viewer";
import styles from "./Selection.module.css";
export const frontstageId = "SelectionFrontstageId";
export const getFrontstageProvider = () =>
FrontstageUtilities.createStandardFrontstage({
id: frontstageId,
usage: StageUsage.General,
hideStatusBar: true,
hideToolSettings: true,
contentGroupProps: {
id: "myCustomPageForWidget",
layout: { id: "myCustomPageForWidgetLayoutId" },
contents: [
{
classId: "",
id: "myCustomPageForWidgetContentId",
content: <SelectIModel accessToken={async () => IModelApp.getAccessToken()} />,
},
],
},
});
const SelectIModel = ({ maxCount, accessToken }: IModelGridProps): ReactElement => {
const [isDownloading, setIsDownloading] = React.useState<boolean>(false);
const selectionHandler = async (iModel: IModelFull): Promise<void> => {
if (!iModel.iTwinId) throw Error("iModel does not have an associated iTwinId property.");
try {
setIsDownloading(true);
await openBriefcase(iModel.iTwinId, iModel.id);
} catch (e) {
Logger.logError(LOG_CATEGORY, e);
setIsDownloading(false);
}
};
return !isDownloading ? (
<div className={styles.scrollingContainer}>
<div className={styles.contentMargins}>
<Text variant="title" data-testid="imodel-selection-title">
{IModelApp.localization.getLocalizedString(`${LOCALE_NAMESPACE}:imodel.selectionTitle`)}
</Text>
</div>
<div className={styles.scrollingContent}>
<IModelGrid
viewMode={"tile"}
accessToken={accessToken}
iTwinId={StudioApp.getITwin()?.iTwinId}
onThumbnailClick={selectionHandler}
maxCount={maxCount}
tileOverrides={{
tileProps: {
"data-testid": "imodel-tile",
} as any,
}}
/>
</div>
</div>
) : (
<div className={styles.loading}>
<div className={styles.loadingIcon}>
<ProgressRadial size="large" indeterminate />
</div>
<Text variant="title">{IModelApp.localization.getLocalizedString(`${LOCALE_NAMESPACE}:imodel.downloading`)}</Text>
</div>
);
};
/** Opens a briefcase from the local cache. */
const openBriefcase = async (iTwinId: string, iModelId: string): Promise<void> => {
Logger.logInfo(LOG_CATEGORY, "Opening briefcase", { id: iModelId });
const { downloadOpenPromise } = await StudioStartupApp.downloadAndOpenBriefcase({
iModelId,
iTwinId,
});
await downloadOpenPromise;
};
  1. Save the file.
  2. Add the necessary dependencies:
Terminal window
pnpm add @itwin/imodel-browser-react @itwin/itwinui-react @itwin/itwinui-icons-react
  1. Note that the file imports a css module for its styles:
import styles from "./Selection.module.css";
  1. Let’s create that file now. Create a file named Selection.module.css in the frontstages directory.
  2. Add the following css code to the file:
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.loading > .loadingIcon {
height: var(--iui-size-2xl);
padding-bottom: 13px;
}
.scrollingContainer {
display: flex;
flex-direction: column;
height: 100%;
}
.scrollingContainer .scrollingContent {
overflow: auto;
flex-basis: 100%;
}
.contentMargins {
margin: 20px calc(var(--responsive-grid-margin, 64px) - 3px) 0;
}
  1. Save the file.
  2. Now let’s add code to set our new frontstage as the active frontstage in our App.
  3. Add the following code to the activateFrontend function in frontend\index.tsx (be sure to include the imports):
import { UiFramework } from "@itwin/appui-react";
import { Logger, LogLevel } from "@itwin/core-bentley";
import { IModelApp } from "@itwin/core-frontend";
import { SvgVisibilityShow } from "@itwin/itwinui-icons-react";
import { type ITwin, StudioApp } from "@bentley/studio-apps-frontend-api";
import { LOCALE_NAMESPACE, LOG_CATEGORY } from "../common/Constants";
import { getFrontstageProvider as SelectionFrontstage } from "./frontstages/Selection";
import React from "react";
const SidebarButtonId = `${LOCALE_NAMESPACE}:viewer`;
/**
* This function is called by StudioApp after it starts up.
* It is called after `IModelApp.startup`.
*/
export async function activateFrontend(): Promise<void> {
Logger.setLevel(LOG_CATEGORY, LogLevel.Trace);
await IModelApp.localization.registerNamespace(LOCALE_NAMESPACE);
const selectionFrontstage = SelectionFrontstage();
UiFramework.frontstages.addFrontstage(selectionFrontstage);
await UiFramework.frontstages.setActiveFrontstage(selectionFrontstage.id);
StudioApp.addSidebarButton({
id: SidebarButtonId,
iconNode: <SvgVisibilityShow />,
label: IModelApp.localization.getLocalizedString(`${LOCALE_NAMESPACE}:viewer.sidebarLabel`),
onClick: async () => {
const activeFrontstageId = selectionFrontstage.id;
await UiFramework.frontstages.setActiveFrontstage(activeFrontstageId);
},
isActive: true,
});
}
  • Let’s also add code to cleanup our actions when our frontend is deactivated:
/**
* This function is called by StudioApp when it shuts down.
* It is called before `IModelApp.shutdown`.
*/
export async function deactivateFrontend() {
IModelApp.localization.unregisterNamespace("StudioAppLocales");
await UiFramework.getIModelConnection()?.close();
}
  1. Save the index.tsx file.
  2. Note that due to iTwin Studio’s hot reloading capabilities, you can now see the iModel Selection frontstage within your iTwin Studio instance.
  3. Select an iTwin in its selection page and then click on one of its iModels in the grid that is rendered. This will download the iModel into a local briefcase. You may be asking, “Great, but now what?”.
  4. Let’s create a Viewer to view its contents.

Add a frontstage with an iTwin Viewer component

Section titled “Add a frontstage with an iTwin Viewer component”
  1. Create a folder called presentation next to frontstages folder
  2. Add a file called SchemaContextProvider.ts in that folder with the following code:
import { SchemaContext } from "@itwin/ecschema-metadata";
import { ECSchemaRpcLocater } from "@itwin/ecschema-rpcinterface-common";
import type { IModelConnection } from "@itwin/core-frontend";
const schemaContextCache = new Map<string, SchemaContext>();
/**
* All tree components delivered with @itwin/tree-widget-react require a SchemaContext to be able to access iModels metadata.
* @param imodel current iModel
* @returns SchemaContext for the current iModel
*/
export function getSchemaContext(imodel: IModelConnection) {
const key = imodel.getRpcProps().key;
let schemaContext = schemaContextCache.get(key);
if (!schemaContext) {
const schemaLocater = new ECSchemaRpcLocater(imodel.getRpcProps());
schemaContext = new SchemaContext();
schemaContext.addLocater(schemaLocater);
schemaContextCache.set(key, schemaContext);
imodel.onClose.addOnce(() => schemaContextCache.delete(key));
}
return schemaContext;
}
  1. Create a file named Viewer.tsx in the frontstages directory.
  2. Add this code to the file:
import React from "react";
import {
ContentGroup,
ContentGroupProvider,
FrontstageUtilities,
StageUsage,
StandardContentLayouts,
UiFramework,
} from "@itwin/appui-react";
import { type IModelConnection, ViewCreator3d, type ViewState } from "@itwin/core-frontend";
import { ViewportComponent } from "@itwin/imodel-components-react";
import { createECSchemaProvider, createECSqlQueryExecutor, createIModelKey } from "@itwin/presentation-core-interop";
import { createCachingECClassHierarchyInspector } from "@itwin/presentation-shared";
import { enableUnifiedSelectionSyncWithIModel } from "@itwin/unified-selection";
import { StudioApp } from "@bentley/studio-apps-frontend-api";
import { getSchemaContext } from "../presentation/SchemaContextProvider";
export const frontstageId = "ViewerFrontstageId";
interface UnifiedSelectionViewportControlProps {
viewState: ViewState;
iModelConnection: IModelConnection;
}
/**
* A ContentGroupProvider that provides a ContentGroup with a single Viewport.
*/
class MyCustomContentGroupProvider extends ContentGroupProvider {
public override async contentGroup(): Promise<ContentGroup> {
const iModelConnection = UiFramework.getIModelConnection();
if (!iModelConnection) throw Error("No iModelConnection");
const viewCreator = new ViewCreator3d(iModelConnection);
const viewState = await viewCreator.createDefaultView();
return new ContentGroup({
id: "content-group",
layout: StandardContentLayouts.singleView,
contents: [
{
id: "Viewer",
classId: "",
content: <UnifiedSelectionViewportContent iModelConnection={iModelConnection} viewState={viewState} />,
},
],
});
}
}
export const getFrontstageProvider = () =>
FrontstageUtilities.createStandardFrontstage({
id: frontstageId,
usage: StageUsage.General,
contentGroupProps: new MyCustomContentGroupProvider(),
});
export const UnifiedSelectionViewportContent = (props: UnifiedSelectionViewportControlProps) => {
React.useEffect(() => {
const iModelAccess = {
...createECSqlQueryExecutor(props.iModelConnection),
...createCachingECClassHierarchyInspector({
schemaProvider: createECSchemaProvider(getSchemaContext(props.iModelConnection)),
}),
key: createIModelKey(props.iModelConnection),
hiliteSet: props.iModelConnection.hilited,
selectionSet: props.iModelConnection.selectionSet,
};
return enableUnifiedSelectionSyncWithIModel({
imodelAccess: iModelAccess,
selectionStorage: StudioApp.getUnifiedSelectionStorage(),
activeScopeProvider: () => "element",
});
}, [props.iModelConnection]);
return <ViewportComponent viewState={props.viewState} imodel={props.iModelConnection} />;
};
  1. Save the file and add the necessary dependencies:
Terminal window
pnpm add @itwin/presentation-components @itwin/imodel-components-react @itwin/presentation-core-interop @itwin/presentation-shared @itwin/unified-selection @itwin/ecschema-rpcinterface-common @itwin/ecschema-metadata
  1. Re-open the Selection.tsx file.
  2. Add the following line in the selectionHandler function after the call to openBriefcase:
void UiFramework.frontstages.setActiveFrontstage(viewerFrontstageId);

Also add UiFramework to the import from @itwin/appui-react:

import { FrontstageUtilities, StageUsage, UiFramework } from "@itwin/appui-react";
  1. Uncomment the line:
// import { frontstageId as viewerFrontstageId } from "./Viewer";
  1. Save the file.
  2. Re-open frontend\index.tsx and add code to register the new frontstage (also add the new import):
import { getFrontstageProvider as ViewerFrontstage } from "./frontstages/Viewer";
/**
* This function is called by StudioApp after it starts up.
* It is called after `IModelApp.startup`.
*/
export async function activateFrontend(): Promise<void> {
Logger.setLevel(LOG_CATEGORY, LogLevel.Trace);
await IModelApp.localization.registerNamespace(LOCALE_NAMESPACE);
const viewerFrontstage = ViewerFrontstage();
UiFramework.frontstages.addFrontstage(viewerFrontstage);
const selectionFrontstage = SelectionFrontstage();
UiFramework.frontstages.addFrontstage(selectionFrontstage);
await UiFramework.frontstages.setActiveFrontstage(selectionFrontstage.id);
StudioApp.addSidebarButton({
id: SidebarButtonId,
iconNode: <SvgVisibilityShow />,
label: IModelApp.localization.getLocalizedString(`${LOCALE_NAMESPACE}:viewer.sidebarLabel`),
onClick: async () => {
const activeFrontstageId = StudioApp.getConnection() ? viewerFrontstage.id : selectionFrontstage.id;
await UiFramework.frontstages.setActiveFrontstage(activeFrontstageId);
},
isActive: true,
});
}
  1. Save the file.
  2. Once your app reloads, select an iTwin and double-click an iModel tile in the grid.
  3. Now, in addition to downloading the local briefcase, it should open the briefcase for rendering in your new iTwin Viewer frontstage.
  4. You may have noticed that there are un-localized strings in the UI. Let’s fix that.
  5. Within the frontend directory, add an assets directory.
  6. Within assets add a locales directory, with an en directory within locales.
  7. Add a file named <app-id>.json.
  8. Add these strings to the file:
{
"imodel": {
"selectionTitle": "Available iModels",
"downloading": "Downloading iModel"
},
"loading": "Loading..."
}
  1. Save the file.
  2. Restart the application to see if the localization is working

Your app with a custom frontstage is now ready!

P.S. this guide created a basic version of the “Viewer Application” template. Try creating an app from that template for comparison and notice how much other functionality comes with it!