Building Configuration UIs with Solis React
This guide shows how to build a marketplace and a rich configuration experience in React with Solis React (@prismatic-io/solis-react), the React package of Solis, Prismatic's custom UI SDK.
The code comes from the Headless Configuration page of the example embedded app, which you can run and read alongside this guide.
Install the SDK
Install solis-react from the preview tag:
npm install @prismatic-io/solis-react@preview
solis-react supports React 18 and 19.
It doesn't support server-side rendering: during a server render, every hook is loading.
Add the provider
Wrap the part of your app that uses Prismatic in PrismaticProvider.
Pass your Prismatic URL and an embedded user's JWT, signed by your backend as described in Authenticating embedded users:
import { PrismaticProvider, usePrismatic } from "@prismatic-io/solis-react";
export function Integrations({ token }: { token: string }) {
return (
<PrismaticProvider prismaticUrl="https://app.prismatic.io" auth={{ token }}>
<Marketplace />
</PrismaticProvider>
);
}
When your app refreshes the token, pass the new one. The provider applies it to the live session without starting over.
usePrismatic() returns the session.
It's loading until the hidden frame connects and accepts the token:
const session = usePrismatic();
if (session.status === "loading") return <Spinner />;
if (session.status === "error")
return <p>Could not connect: {session.error.message}</p>;
Resources, actions, and permissions
Every hook follows the same rules:
- A hook returns a resource:
{ status: "loading" },{ status: "error", error }, or{ status: "success", data }. Readdataonly after checkingstatus. - Changes are actions on
resource.actions.execute()resolves to a result,{ status: "success", data }or{ status: "error", error }, and never throws. An action also has its ownstatus, so you can disable a button while it runs. - Each guarded action has a permission at
data.permissions.<action>:{ allowed: true }, or{ allowed: false, reason }.
List the marketplace
useMarketplace() lists the integrations the customer can activate.
Each item is its own resource:
import { useMarketplace } from "@prismatic-io/solis-react";
function Marketplace() {
const marketplace = useMarketplace({ includeActiveIntegrations: true });
if (marketplace.status === "loading") return <Spinner />;
if (marketplace.status === "error") return <p>{marketplace.error.message}</p>;
return marketplace.data.items.map((item) =>
item.status === "success" ? (
<IntegrationCard
key={item.id}
name={item.data.name}
experience={item.data.configurationExperience}
instances={item.data.instances}
/>
) : null,
);
}
Each listing includes configurationExperience, headless or hosted, and instances, the customer's instances of that integration.
Activate an integration
useMarketplaceIntegration(id) returns one listing.
Its createInstance action creates an instance that isn't deployed yet:
const integration = useMarketplaceIntegration(integrationId);
if (integration.status !== "success") return null;
const { createInstance } = integration.actions;
return (
<button
disabled={
!integration.data.permissions.createInstance.allowed ||
createInstance.status === "loading"
}
onClick={() => createInstance.execute({ name: integration.data.name })}
>
Activate {integration.data.name}
</button>
);
Lists refresh on their own afterward, so the new instance appears in integration.data.instances.
Configure an instance
Configuring an instance takes four steps: load the configuration, run init, save, and deploy.
Load the configuration
useConfiguration({ instanceId }) returns the configuration of the version the instance runs.
Its data includes the schema, the ui layout, configurationExperience, and serverFunctions:
const instance = useInstance(instanceId);
const configuration = useConfiguration({ instanceId });
ui is the layout the integration declares: { type: "uiSchema", value } with a JSON Forms layout, or { type: "custom", value } with a value your app defines.
It's absent or null when the integration has no layout.
To use a JSON Forms layout:
const { ui } = configuration.data;
const uiSchema = ui?.type === "uiSchema" ? ui.value : undefined;
Run init
init returns the values the integration suggests.
It saves nothing, so it's safe to run as soon as the form opens:
// What the example integration's init returns
interface InitProposal {
configuration: FakeCrmConfiguration;
unresolvedReasons?: string[];
}
const [proposal, setProposal] = useState<InitProposal | null>(null);
useEffect(() => {
if (configuration.status !== "success") return;
configuration.actions.init.execute({}).then((result) => {
if (result.status === "success") setProposal(result.data as InitProposal);
});
}, [configuration.status]);
init's result includes configuration, the suggested values, and userConfiguration when the integration asks each user for settings.
The integration can add other properties for your app.
The example integration adds unresolvedReasons.
Use configuration as your form's starting values, and show any unresolvedReasons for the customer to review.
Prismatic doesn't validate init's suggestions against the schema.
It validates the values your app saves.
Save and deploy
save stores the customer's values.
Prismatic validates them against the schema first.
Then deploy makes the instance run with them:
const [fieldErrors, setFieldErrors] = useState<
readonly { path: string | null; message: string }[]
>([]);
const submit = async (value: FakeCrmConfiguration) => {
const saved = await configuration.actions.save.execute({ value });
if (saved.status === "error") {
// PRISMATIC_CONFIGURATION_INVALID lists each invalid field
setFieldErrors(saved.error.fields ?? []);
return;
}
await instance.actions.deploy.execute();
};
Saving never deploys.
Until your app calls deploy, a deployed instance keeps running with its previous values, and its lifecycle is pendingChanges.
A new instance stays notDeployed until its first deploy.
Flow schedules
An integration can let whoever deploys an instance choose when a flow runs, by giving the flow schedule: { fromDeployer: true }.
configuration.data.flows lists the flows of the version being configured, with each flow's id, name, stableId, and scheduleFromDeployer.
Pass schedules to save alongside the values:
await configuration.actions.save.execute({
value: formValues,
flows: [
{
flowId: selectedFlow.id,
schedule: { expression: "0 9 * * *", timezone: "America/Phoenix" },
},
],
});
- An
expressionis a cron string,"once", or"none"to turn off scheduled runs. A missing ornulltimezone means UTC. - Omit
flows, or a flow, to keep its current schedule. A flow that newly requires a schedule from the deployer needs one before the configuration is complete. - When saving an update, use the flow IDs of the version you're moving to. Schedules carry over for flows with the same stable ID.
The instance's data.flows includes each flow's saved schedule.
Render the form
Your app can render a form generically from configuration.data.schema, or build a form for one integration.
The example app's Fake CRM form is built for one integration.
It still reads each field's label and whether it's required from the schema, so a new field in the integration appears without an app change.
Calling server functions
useServerFunction(configuration, key) returns an action that runs one of the integration's server functions.
Type its inputs and result with generics:
interface Field {
name: string;
label: string;
}
const listFields = useServerFunction<{ recordType: string }, Field[]>(
configuration,
"listFields",
);
const result = await listFields.execute({ inputs: { recordType: "Contact" } });
if (result.status === "success") setFields(result.data);
Things to know:
- One call at a time. An action runs one call at a time.
Calling
execute()while a call runs resolves to aPRISMATIC_ACTION_BUSYerror. To run the same function side by side, calluseServerFunctionmore than once. - Results aren't validated. The generics type the result, but nothing checks it at runtime.
- Unsaved values are inputs. A server function reads only saved configuration from its context. Pass the customer's unsaved choices as inputs.
To refetch a function whenever its inputs change, like a query, the example app uses a small hook, useServerFunctionQuery.
It runs the function again with the newest inputs when a call finishes, so calls never overlap.
Connections
configuration.data.connections is a resource that loads after the configuration.
It lists the connections init and each server function need.
Each requirement has a key, a label, and options, the customer's existing connections that can fill it:
const { connections } = configuration.data;
if (connections.status === "success") {
for (const requirement of connections.data.init) {
console.log(
requirement.key,
requirement.options.map((option) => option.id),
);
}
}
Keep the customer's choices as { key, id } selections, and pass them to init and server functions:
await configuration.actions.init.execute({ connections: selections });
await listChannels.execute({ inputs: { search }, connections: selections });
A requirement backed by a customer-activated connection can create a new connection with requirement.actions.createConnection.
For OAuth 2.0, it opens the provider's consent screen, so call it directly from a click handler.
Routing between headless and hosted configuration
Integrations that use config pages report a configurationExperience of hosted.
For those, render HostedConfiguration, which opens Prismatic's config wizard:
import { HostedConfiguration } from "@prismatic-io/solis-react";
if (configuration.data.configurationExperience === "hosted") {
return (
<HostedConfiguration
instanceId={instanceId}
className="h-full"
onClose={close}
/>
);
}
return <MyConfigurationForm configuration={configuration} />;
After the wizard changes something, the SDK refreshes instances, the marketplace, and configurations, so the rest of your UI updates.
Updates
An instance's data.update is null when the instance is on the newest version.
Otherwise it's { integrationVersionId, versionNumber, requiresReconfiguration }:
-
When
requiresReconfigurationisfalse, the configuration's shape didn't change. Callupgrade, thendeploy:const moved = await instance.actions.upgrade.execute();if (moved.status === "success") await instance.actions.deploy.execute(); -
When
requiresReconfigurationistrue, load the new version's configuration and configure it as usual. Saving it moves the instance to the new version:const configuration = useConfiguration({instanceId,integrationVersionId: instance.data.update?.integrationVersionId,});After you save, the instance has no update, so remember which version is under review until the customer finishes.
See Configuration versions and init for how integration authors decide which kind of update they publish.
Instance status
An instance's data.lifecycle summarizes its status:
| Lifecycle | Meaning |
|---|---|
notDeployed | Created, but never deployed |
needsReconfiguration | The instance needs its configuration reviewed |
needsUserConfiguration | The current user needs to save their user-level settings |
paused | Paused |
pendingChanges | Saved changes haven't been deployed |
active | Running |
The instance's actions include pause, resume, and remove, each with a matching permission.
Next steps
- Get started with headless configuration to run the example end to end