The goal is to create a custom react widget for our workshop application.
We’ll need to:
- Create a Developer console application with an OSDK
- Create the React application
- Host it as a website (hosted application) in Foundry
- Iframe the hosted app in Workshop (we will have bidirectional variables/events).
Step 1. Prerequisite
- [Optional] Create an Object Type to use in our application. You can reuse an existing Object Type if you have one (most likely)
a. Create a pipeline builder to create a few rows
b. Create an Object Type from the dataset produced in pipeline builde
Step 2. Create a Developer console application with an OSDK
-
Create a Developer Console application
a. Opening the “Developer console” application (navigate to /workspace/developer-console/) and follow the wizard to create a new application
b. Select the object previously created or the object(s) you want to access in the application
c. Pick the “client facing application” choice given users will login “as themselves” in the app
-
Request a domain for the app to be hosted
a. For example myappcustomwidget.myfoundryinstance.com
b. You might need an Admin on your Foundry enrollment to approve and enable the subdomain in Control Panel
-
Configure the permission of the app
a. Enable this application for your organization (you might need to ask your organization administrator)
b. Enable this application for the organization of the future users of the app (you will have some warning in the UI, see below).
Note: Being guest of an org doesn’t matter. It’s the base org that matters (if you are part of “Org A” when you login, regardless of the other orgs you are guest of, then you need to get this app activated for “Org A”). You will face error like “Authorization error invalid_request Client Authentication failed” if this is not rightly setup.
You can see the approvals and actions required as warnings in the UI.
- Configure the authentication URL
You need to add a URL as a redirect URL during the authentication flow. In short, this callback URL is useful because once the login process is finished, the callback url is the url where the user will be redirected, hence the url where the app is hosted.
- if you dev locally, you will need a localhost callback as you want to be redirect to your local app.
- If you dev on Foundry in VSCode, a dedicated url needs to be called, specific to the code repository you are working in. This URL is auto-generated for you once you start developing your react app in VSCode (so a bit later in this tutorial)
Step 3. Create the React application
- In Developer Console, create a code repository to develop your app.
a. You can create a VSCode environment directly in Foundry, where you will have a side by side code and preview of the app your are developing.
b. Once created the hosted app application and the repository will be “linked” as in this code repo will be able to publish new version of this hosted app.
- Once you open the code repository, you will be warned that the OAuth won’t work until you register this workspace. Click on the button to register your workspace.
You can use the documentation in Developer Console to get the syntax to query the Ontology (access objects, edit objects via Action, call functions, …)
You will need to add a few dependencies in package.json.
Namely:
@osdk/foundry
will give you access to Users objects and additional APIs
@osdk/workshop-iframe-custom-widget
will give you access to a package that let you handle the bidirectional communication between Workshop and your application. More information at https://www.palantir.com/docs/foundry/workshop/widgets-iframe/#configure-custom-widgets
"dependencies": {
"@osdk/client": "^2.0.8",
"@osdk/foundry": "^2.6.0",
"@osdk/oauth": "^1.0.0",
"@osdk/workshop-iframe-custom-widget": "^1.0.5",
Here is an example of main.tsx.
This will define the routes of your application (which will be loaded as a widget in workshop). Namely, it will authenticate the user and then display the “WidgetWrapper” component.
import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import AuthCallback from "./AuthCallback";
import { WidgetWrapper } from "./Home";
import "./index.css";
const router = createBrowserRouter(
[
{
path: "/",
element: <WidgetWrapper />,
},
{
// This is the route defined in your application's redirect URL
path: "/auth/callback",
element: <AuthCallback />,
},
],
{ basename: import.meta.env.BASE_URL },
);
ReactDOM.createRoot(document.getElementById("root")!).render(
<RouterProvider router={router} />,
);
Here is an example Home.tsx.
There are actually 2 components in this code:
- The
WidgetWrapper
component
- The actual
MyCustomWidget
component
### The WidgetWrapper
component
The goal of this widget is to load a Workshop context.
* It contains everything to interact with the Workshop parent application: Accessing variable, setting values of variables, executing events.
* The WidgetWrapper
takes care of loading the context and if succesful, load the actual widget you want to present (here, the MyCustomWidget
component).
* The Workshop context is defined by a configuration file that describes the “interface” or “API” that your widget exposes to Workshop. (see below for more information)
### The MyCustomWidget
component
This is the actual widget you want to display on screen.
* In this case, the MyCustomWidget
component was built on top of the default component, where there is a list of button to set variables of different types, trigger events in the parent Workshop, and set Object values in variables.
* It is as well loading data from the Ontology by using the OSDK that was generated in the Developer console application.
* Arbitrary third party libraries can be used, for instance I’m using the react-typing-effect
library to have a text “typing” effect when written on screen.
* Information about the current user can be fetched, alongside accessing Platform APIs, but using the platformClient
* You can install dependencies via npm
, for example npm install react-typing-effect
import React from "react";
import {
$Objects,
$Actions,
$Queries,
} from "@react-widget-developer-console-example/sdk";
import css from "./Home.module.css";
import Layout from "./Layout";
import ReactTypingEffect from "react-typing-effect";
import "./TypingEffect.css";
import { ExampleObjectForOsdkReactApp } from "@react-widget-developer-console-example/sdk";
import { Osdk } from "@osdk/client";
import client from "./client";
import platformClient from "./client";
import { getCurrent } from "@osdk/foundry.admin/User";
import { User } from "@osdk/foundry.admin";
import {
IAsyncValue,
isAsyncValue_Loaded,
IWorkshopContext,
useWorkshopContext,
visitLoadingState,
} from "@osdk/workshop-iframe-custom-widget";
import { EXAMPLE_CONFIG } from "./config";
// ========= Wrapper widget =========
// Application wrapper for Workshop state loading
export const WidgetWrapper = () => {
// useWorkshopContext() is imported from an npm library:
// - it takes in the definition of input values required from Workshop, and the outputs values that are sent to Workshop, and events that should be configured in Workshop
// - Returns a context object with an API that can be called to get values or set Workshop variables or execute Workshop events
//
// Example of getting an input value from Workshop:
// workshopContext["title"].getValue() -> returns string
//
// Example of setting an output value in Workshop:
// workshopContext["selectedTimelineObject"].set(value) -> void
//
// Example of executing an event in Workshop:
// workshopContext["eventOnTimelineClick"].executeEvent() -> void
//
const workshopContext = useWorkshopContext(EXAMPLE_CONFIG);
console.log(workshopContext.status)
// Note: we can have a proper management of the state on loading etc.
return visitLoadingState(workshopContext, {
loading: () => <>LOADING...</>,
// If the Workshop context was loaded successfully, we pass it to our custom widget
succeeded: (value) => {
console.log("Workshop context loaded successfully:", value);
return <MyCustomWidget loadedWorkshopContext={value} />
},
reloading: (previousValue) => {
console.log("Workshop context is reloading:", previousValue);
return <MyCustomWidget loadedWorkshopContext={previousValue} />
},
failed: (err) => {
console.error("Failed to load workshop context:", err);
return <div>SOMETHING WENT WRONG...</div>;
},
});
};
// ========= Utils functions =========
// Loads the value from the Workshop context. It will validate the value is present and has been loaded.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getValueFromAsyncValue = (asyncVal: IAsyncValue<any> | undefined) => {
if (asyncVal != null && isAsyncValue_Loaded(asyncVal)) {
return JSON.stringify(asyncVal.value);
}
return undefined;
};
// ========= Custom widget =========
// Defines the interface of our "MyCustomWidget" widget
interface MyCustomWidgetProps {
loadedWorkshopContext: IWorkshopContext<typeof EXAMPLE_CONFIG>;
}
// Defines the custom widget itself
const MyCustomWidget: React.FC<MyCustomWidgetProps> = props => {
// We populat variables from the properties passed to the widget
const { loadedWorkshopContext } = props;
// Example Ontology query
// We query all the objects - example inspired from autogenerated docs in Developer console
const [objects, setObjects] = React.useState<
Osdk.Instance<ExampleObjectForOsdkReactApp, "$rid">[]
>([]);
React.useEffect(() => {
// Async function as we are performing an async call
const fetchComponent = async () => {
const objects: Osdk.Instance<ExampleObjectForOsdkReactApp, "$rid">[] = [];
// We iterate over all the objects of the set and load them in memory
for await (const obj of client(
ExampleObjectForOsdkReactApp
// We include the RID of the object, as we need them to populate workshop's object variable
).asyncIter({$includeRid: true})) {
objects.push(obj);
}
setObjects(objects);
};
fetchComponent();
}, []);
// Setters
const setStringFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.stringField.setLoadedValue(Math.random().toString());
}, [loadedWorkshopContext]);
const setNumberFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.numberField.setLoadedValue(Math.random() * 100);
}, [loadedWorkshopContext]);
const setBooleanFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.booleanField.setLoadedValue(Math.random() < 0.5);
}, [loadedWorkshopContext]);
function getRandomDate(): Date {
const start = new Date(2020, 0, 1); // January 1, 2020
const end = new Date(2023, 11, 31); // December 31, 2023
const startTime = start.getTime();
const endTime = end.getTime();
const randomTime = Math.random() * (endTime - startTime) + startTime;
return new Date(randomTime);
}
const setDateFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.dateField.setLoadedValue(getRandomDate());
}, [loadedWorkshopContext]);
const setTimestampFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.timestampField.setLoadedValue(getRandomDate());
}, [loadedWorkshopContext]);
const setStringListFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.stringListField.setLoadedValue([Math.random().toString(), Math.random().toString()]);
}, [loadedWorkshopContext]);
const setBooleanListFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.booleanListField.setLoadedValue([Math.random() < 0.5, Math.random() < 0.5]);
}, [loadedWorkshopContext]);
const setNumberListFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.numberListField.setLoadedValue([Math.random() * 100, Math.random() * 100]);
}, [loadedWorkshopContext]);
const setDateListFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.dateListField.setLoadedValue([getRandomDate(), getRandomDate()]);
}, [loadedWorkshopContext]);
const setTimestampListFieldValue = React.useCallback(() => () => {
loadedWorkshopContext.timestampListField.setLoadedValue([getRandomDate(), getRandomDate()]);
}, [loadedWorkshopContext]);
const triggerEventInWorkshop = React.useCallback(() => () => {
loadedWorkshopContext.event.executeEvent()
}, [loadedWorkshopContext]);
const objectApiNames = Object.keys($Objects);
const actionApiNames = Object.keys($Actions);
const queryApiNames = Object.keys($Queries);
const [user, setUser] = React.useState<User>();
const getProfile = React.useCallback(async () => {
const result = await getCurrent(platformClient);
setUser(result);
}, []);
const handleButtonClick = (
action: string,
obj: Osdk.Instance<$Objects.ExampleObjectForOsdkReactApp, "$rid">
) => {
console.log(`Action: ${action}, Object: ${obj}`);
// We set this object to the Workshop variable
if(obj.$primaryKey !== undefined){
loadedWorkshopContext.objectSetField.setLoadedValue([{ $rid: obj.$rid, $primaryKey: obj.$primaryKey }])
}
};
getProfile();
return (
<Layout>
<div className={css.topLeftSection}>
<h1 className="typing">
<ReactTypingEffect
text={["Welcome " + user?.username]}
speed={100}
typingDelay={500}
eraseDelay={9999999} // Large delay to prevent erasing
cursor=" "
/>
</h1>
<h2 className="sub-typing">
<ReactTypingEffect
text={[
"Choose amongst the below apps to get started with the future of AI",
]}
speed={50}
eraseDelay={9999999} // Large delay to prevent erasing
/>
</h2>
</div>
<div className={css.container}>
<div className={css.leftSection}>
{
// We display the environement variables one below each other, loaded from the Workshop Context
}
<div>
<h2>Environment Values from Parent Workshop (if any)</h2>
<p>String Field: {getValueFromAsyncValue(loadedWorkshopContext.stringField.fieldValue)}</p>
<button onClick={setStringFieldValue()}>Set a random value</button>
<p>Number Field: {getValueFromAsyncValue(loadedWorkshopContext.numberField.fieldValue)}</p>
<button onClick={setNumberFieldValue()}>Set a random value</button>
<p>Boolean Field: {getValueFromAsyncValue(loadedWorkshopContext.booleanField.fieldValue)}</p>
<button onClick={setBooleanFieldValue()}>Set a random value</button>
<p>Date Field: {getValueFromAsyncValue(loadedWorkshopContext.dateField.fieldValue)}</p>
<button onClick={setDateFieldValue()}>Set a random value</button>
<p>Timestamp Field: {getValueFromAsyncValue(loadedWorkshopContext.timestampField.fieldValue)}</p>
<button onClick={setTimestampFieldValue()}>Set a random value</button>
<p>Object Set Field: {getValueFromAsyncValue(loadedWorkshopContext.objectSetField.fieldValue)}</p>
<button disabled={true}>Set a random value</button>
<p>String List Field: {getValueFromAsyncValue(loadedWorkshopContext.stringListField.fieldValue)}</p>
<button onClick={setStringListFieldValue()}>Set a random value</button>
<p>Number List Field: {getValueFromAsyncValue(loadedWorkshopContext.numberListField.fieldValue)}</p>
<button onClick={setNumberListFieldValue()}>Set a random value</button>
<p>Boolean List Field: {getValueFromAsyncValue(loadedWorkshopContext.booleanListField.fieldValue)}</p>
<button onClick={setBooleanListFieldValue()}>Set a random value</button>
<p>Date List Field: {getValueFromAsyncValue(loadedWorkshopContext.dateListField.fieldValue)}</p>
<button onClick={setDateListFieldValue()}>Set a random value</button>
<p>Timestamp List Field: {getValueFromAsyncValue(loadedWorkshopContext.timestampListField.fieldValue)}</p>
<button onClick={setTimestampListFieldValue()}>Set a random value</button>
</div>
</div>
<div className={css.rightSection}>
<p>
Welcome to your Ontology SDK! Try using any of the following methods
now.
</p>
<button onClick={triggerEventInWorkshop()}>Trigger an event in Workshop</button>
<div className={css.methods}>
<div>
<h2>Objects Types ({objectApiNames.length})</h2>
{objectApiNames.map((objectApiName) => (
<pre key={objectApiName}>$Objects.{objectApiName}</pre>
))}
</div>
<div>
<h2>Actions Types ({actionApiNames.length})</h2>
{actionApiNames.map((actionApiName) => (
<pre key={actionApiName}>$Actions.{actionApiName}</pre>
))}
</div>
<div>
<h2>Queries ({queryApiNames.length})</h2>
{queryApiNames.map((queryApiName) => (
<pre key={queryApiName}>$Queries.{queryApiName}</pre>
))}
</div>
<div>
<h2>Object Instances ({objects.length})</h2>
<div className={css.objectInstances}>
{objects.map((obj) => (
<div key={obj.$title} className={css.card}>
<h3>{obj.objectTitle}</h3>
<p>{obj.comment}</p>
<button onClick={() => handleButtonClick("view", obj)}>
View
</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</Layout>
);
};
Here is the Home.module.css, for reference.
There isn’t something specific to the integration with Workshop here.
.methods {
padding: 2em;
gap: 2em;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.topLeftSection {
position: absolute;
text-align: left;
top: 20px;
left: 20px;
padding: 20px;
background: black;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.container {
display: flex;
justify-content: space-between;
}
.leftSection, .rightSection {
width: 48%;
text-align:left;
background: rgb(21, 21, 21);
padding:10px;
}
.rightSection {
display: flex;
flex-direction: column;
}
Here is an example of a config.ts.
The Workshop context is defined by this configuration file that describes the “interface” or “API” that your widget exposes to Workshop. Once in Workshop, this configuration will be displayed as the configuration of a usual widget.
This configuration is the way that Workshop knows what it can pass/get to/from the iframed widget.
import { IConfigDefinition } from "@osdk/workshop-iframe-custom-widget";
export const EXAMPLE_CONFIG = [
{
fieldId: "stringField",
field: {
type: "single",
fieldValue: {
type: "inputOutput",
variableType: {
type: "string",
defaultValue: "test",
},
},
label: "string field",
},
},
{
fieldId: "numberField",
field: {
type: "single",
label: "number field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "number",
defaultValue: 25,
},
},
},
},
{
fieldId: "booleanField",
field: {
type: "single",
label: "boolean field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "boolean",
defaultValue: undefined,
},
},
},
},
{
fieldId: "dateField",
field: {
type: "single",
label: "date field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "date",
defaultValue: new Date("2024-01-01"),
},
}
},
},
{
fieldId: "timestampField",
field: {
type: "single",
label: "timestamp field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "timestamp",
defaultValue: new Date("2024-12-31"),
},
},
},
},
{
fieldId: "objectSetField",
field: {
type: "single",
label: "object set field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "objectSet",
objectTypeId: "dmtqpacj.example-object-for-osdk-react-app",
defaultValue: {
type: "string",
primaryKeys: ["52c21f70-7ce9-487b-9d88-35da9f05ac9d"],
}
},
},
},
},
{
fieldId: "stringListField",
field: {
type: "single",
label: "string list field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "string-list",
defaultValue: ["hello", "world"],
},
},
},
},
{
fieldId: "numberListField",
field: {
type: "single",
label: "number list field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "number-list",
defaultValue: [1, 3]
},
},
},
},
{
fieldId: "booleanListField",
field: {
type: "single",
label: "boolean list field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "boolean-list",
defaultValue: [true, false],
},
},
},
},
{
fieldId: "dateListField",
field: {
type: "single",
label: "date list field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "date-list",
defaultValue: [new Date("2023-01-01"), new Date("2024-01-01")]
},
},
},
},
{
fieldId: "timestampListField",
field: {
type: "single",
label: "timestamp list field",
fieldValue: {
type: "inputOutput",
variableType: {
type: "timestamp-list",
defaultValue: undefined,
},
},
},
},
{
fieldId: "event",
field: {
type: "single",
label: "Events",
fieldValue: {
type: "event",
},
},
},
] as const satisfies IConfigDefinition;
Without such configuration, this you will get this error in Workshop:
Failed to load Bidirectional Iframe config
No configuration definition received from the iframed app. Have you configured your app to send the configuration definition to workshop using workshop-iframe-react-app-plugin?
Before publishing a first version of your react widget, you will need to fill in the redirect url in .env.production
with the redirect URL of your hosted app url, accessible in Developer Console.
Development loop:
- You can “refresh” (button top right) to see the current state of your react application, and interact with it
- You can “push” your changes (aka “save”) but executing git command in the terminal in VSCode (bottom). Behind the scenes, VSCode runs in Code Workspace, but the versioning is done in Code repository. The backing code repository doesn’t change whenever you change your code. You need to “push” those changes to the code repository. For instance:
git add —all
git commit -m “my update”
git push
- If there is a bug or you get stuck and want to try to “reboot” your VSCode, you can save (with the above procedure) your work, and then restart your environment (top right:
“Active” > “Restart workspace”
with or without checkpoint - with or without keep your unsaved changes)
- Once you are happy with your work and you want to publish a new version of the app, you need to save (see above) and then trigger the release of a new version. Go in the left sidebar and look for
Generate new version
in the “website hosting” section. This will open the tags page on the backing code repository, where you can create a new tag (top right) to trigger a new tag release (a new version of your app/widget).
You will see the published version of your hosted app in Developer Console.
You can as well directly access your application via the subdomain url you requested earlier.
Step 4. Iframe your application/widget in Workshop (with bidirectional variables/events).
Once published, you can add the “custom widget via iframe” widget in Workshop.
The configuration will require the URL of your hosted application.
As a user, on the first login to the app, the user will by default need to allows or not the app to access specific permissions.
If you do not want users to have to allow the application access, you can turn on Organization level consent for this application in Control Panel
Well done, you now have a custom React widget usable in your Workshop applications !