Skip to main content

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:

npm install @prismatic-io/solis-react@latest

solis-react supports React 18 and 19. It doesn't support server-side rendering: during a server render, every hook is loading. For details on connecting, refreshing tokens, and verifying the session, see Installing the headless SDK.

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:

App.tsx
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 }. Read data only after checking status.
  • 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 own status, 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:

Marketplace.tsx
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 expression is a cron string, "once", or "none" to turn off scheduled runs. A missing or null timezone 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.

Presenting a configuration​

Solis doesn't decide how a configuration looks, so you can present the same configuration in whatever layout fits your app and your customers:

PresentationSuits
Single pageShort setups, and customers who want to see every question and change answers in any order
Multi-step wizardLonger setups, and questions whose choices depend on an earlier answer, such as fields that depend on a record type
One-click connectIntegrations whose init can suggest every required value, so the customer only authorizes a connection

The layout changes only what your app renders. Every presentation loads the configuration, runs init, saves, and deploys with the same calls, and Prismatic validates the saved values the same way.

A Fake CRM configuration presented on a single page, with a record type picker, a field mapper, and a live preview

The example app's Presentation Modes page presents the Fake CRM configuration as a single page or a two-step wizard, and lets the customer switch between them mid-setup. These techniques keep both layouts working:

  • Keep the values in one place. The form holds the customer's answers in one state, and each layout arranges the same pieces: the record type picker, the field mapper, and the submit button. Switching layouts changes only a layout prop, so React keeps the answers. If each layout is its own component, lift the values into a parent, or switching resets the form.
  • Gate each step on its answers. A step's Next button can wait for the values the next step depends on, and the submit button can wait for every required field in the schema. Prismatic still validates the values when you save, and returns any errors by field path, so show each one at its field.
  • Run server functions when their result is on screen. Call a server function, such as a live preview, only once its step is showing and its inputs are known. Pass the customer's unsaved values as inputs, because a server function reads only saved configuration.
  • Take the order from the integration. JSON Schema properties have no order, so read the field order from the integration's ui layout. An integration can declare { type: "custom", value } with a layout your app defines, such as its wizard's steps.

For a walkthrough of the example, see Present a configuration as a page or a wizard. For one-click connect, see Build a one-click connect list with Solis.

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 a PRISMATIC_ACTION_BUSY error. To run the same function side by side, call useServerFunction more 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 requiresReconfiguration is false, the configuration's shape didn't change. Call upgrade, then deploy:

    const moved = await instance.actions.upgrade.execute();
    if (moved.status === "success") await instance.actions.deploy.execute();
  • When requiresReconfiguration is true, 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:

LifecycleMeaning
notDeployedCreated, but never deployed
needsReconfigurationThe instance needs its configuration reviewed
needsUserConfigurationThe current user needs to save their user-level settings
pausedPaused
pendingChangesSaved changes haven't been deployed
activeRunning

The instance's actions include pause, resume, and remove, each with a matching permission.

Next steps​