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.
Prerequisites
Section titled “Prerequisites”Install the app template
Section titled “Install the app template”-
Follow the steps in the Getting Started section guide to create a new iTwin Studio app with the basic template:
-
Enter a name for your app when prompted.
-
Select “Basic” as the app type.
Install dependencies
Section titled “Install dependencies”- From within the terminal, navigate to the child directory that is named after your app (
cd ./my-app-name). - Execute
pnpm install.
Load the app into iTwin Studio
Section titled “Load the app into iTwin Studio”- Open your app in VS Code and launch the
Debug in Studiodebugger configuration. - This will open your installed iTwin Studio with your application in context and will run your application in dev mode.
- 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”- Create a directory within the
frontenddirectory namedfrontstages - Add a file named
Selection.tsxin thefrontstagesdirectory. - 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;};- Save the file.
- Add the necessary dependencies:
pnpm add @itwin/imodel-browser-react @itwin/itwinui-react @itwin/itwinui-icons-react- Note that the file imports a css module for its styles:
import styles from "./Selection.module.css";- Let’s create that file now. Create a file named
Selection.module.cssin thefrontstagesdirectory. - 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;}- Save the file.
- Now let’s add code to set our new frontstage as the active frontstage in our App.
- Add the following code to the
activateFrontendfunction infrontend\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();}- Save the
index.tsxfile. - Note that due to iTwin Studio’s hot reloading capabilities, you can now see the iModel Selection frontstage within your iTwin Studio instance.
- 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?”.
- 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”- Create a folder called
presentationnext tofrontstagesfolder - Add a file called
SchemaContextProvider.tsin 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;}- Create a file named
Viewer.tsxin thefrontstagesdirectory. - 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} />;};- Save the file and add the necessary dependencies:
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- Re-open the
Selection.tsxfile. - Add the following line in the
selectionHandlerfunction after the call toopenBriefcase:
void UiFramework.frontstages.setActiveFrontstage(viewerFrontstageId);Also add UiFramework to the import from @itwin/appui-react:
import { FrontstageUtilities, StageUsage, UiFramework } from "@itwin/appui-react";- Uncomment the line:
// import { frontstageId as viewerFrontstageId } from "./Viewer";- Save the file.
- Re-open
frontend\index.tsxand 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, });}- Save the file.
- Once your app reloads, select an iTwin and double-click an iModel tile in the grid.
- Now, in addition to downloading the local briefcase, it should open the briefcase for rendering in your new iTwin Viewer frontstage.
- You may have noticed that there are un-localized strings in the UI. Let’s fix that.
- Within the
frontenddirectory, add anassetsdirectory. - Within
assetsadd alocalesdirectory, with anendirectory withinlocales. - Add a file named
<app-id>.json. - Add these strings to the file:
{ "imodel": { "selectionTitle": "Available iModels", "downloading": "Downloading iModel" }, "loading": "Loading..."}- Save the file.
- 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!