Examplary
Start for free
Developer docs

App settings

Settings are what gets filled in when an app is installed, like an API token or the URL of a school's environment. Examplary shows them as a form on the app's card, validates them, and passes them to every hook of the app.

app.ts
app.addSetting({
  key: "environment",
  label: { en: "Environment" },
  type: "select",
  options: [
    { value: "eu", label: { en: "Europe" } },
    { value: "us", label: { en: "United States" } },
  ],
  default: "eu",
  schema: z.enum(["eu", "us"]),
});

Workspace and user settings

Each setting has a scope, which decides who fills it in:

  • org: an admin, once for the whole workspace, under Settings → Apps & integrations. Use this for things like a school's API key.
  • user: each teacher, for themselves, under Settings → My apps. Use this when every teacher connects their own account, like their Google account.

A setting without a scope uses the app's scope, and org when the app has none either. An app can mix both: the workspace installs the app with its org settings, after which teachers fill in their own. Hooks get both, merged.

A teacher can only fill in their settings once the workspace has installed the app.

Field types

TypeShown as
textA text input
passwordA text input that hides what's typed
urlA URL input
booleanA switch
selectA dropdown with the given options
componentYour own React component, see below

Other properties of a setting:

PropertyDescription
keyThe key its value is stored under, like apiToken. Required.
labelThe field's label. A setting without a label isn't shown: use these for values your hooks set.
helpHelp text shown under the field.
placeholderPlaceholder text.
defaultThe initial value, a string or (for boolean) a boolean.
schemaA zod schema the value has to match. Fields that accept undefined are optional.
optionalWhether the field may be left empty, when there's no schema to tell.
secretStore the value, but never send it back to the browser. Use this for passwords and tokens.
scopeorg or user, see above.

Checking and preparing settings

Before saving, Examplary checks the values against each setting's schema. To check more, like whether an API token actually works, add a validate step. It gets the settings and has to return true; throw an error to show its message to the person filling in the form:

app.ts
app.addSettingsValidateStep(async (settings) => {
  const res = await fetch("https://api.acme.com/me", {
    headers: { Authorization: `Bearer ${settings.apiToken}` },
  });
  if (!res.ok) {
    throw new Error("Acme didn't accept this API token.");
  }
  return true;
});

A prepare step runs before that, and can turn what was filled in into what you'd like to store. It returns the settings to save, which are then checked as usual. A common use is swapping a one-time sign-in code for a long-lived token, which you then store in a secret setting without a label:

app.ts
app.addSetting({
  key: "refreshToken",
  type: "text",
  secret: true,
  schema: z.string(),
});
 
app.addSettingsPrepareStep(async (settings) => ({
  refreshToken: await exchangeCode(settings.authorizationCode),
}));

To clean up when a workspace removes your app, add an uninstall step. It gets the workspace's settings; if it throws, the app stays installed and the admin sees your message:

app.ts
app.addUninstallStep(async (settings) => {
  await disconnectWorkspace(settings.apiToken);
});

Component settings

When a form field isn't enough, like for a "Sign in with…" button that opens a popup, a setting can render your own component:

app.ts
app.addSetting({
  key: "authorizationCode",
  label: { en: "Acme account" },
  type: "component",
  component: {
    path: "./components/connect-button.tsx",
    translations: {
      connect: { en: "Connect your Acme account" },
    },
  },
  schema: z.string().optional(),
});

The component gets setValue(key, value) to set the value of any of the app's settings, and submit() to save them. A card with a component setting has no save button of its own, so the component has to call submit():

components/connect-button.tsx
import { Button, FrontendSettingsFieldComponent } from "@examplary/ui";
 
const ConnectButton: FrontendSettingsFieldComponent = ({
  t,
  setValue,
  submit,
}) => (
  <Button
    onClick={async () => {
      setValue("authorizationCode", await signInWithAcme());
      await submit();
    }}
  >
    {t("connect")}
  </Button>
);
 
export default ConnectButton;

Buttons on the app's card

A SettingsButton adds a button to the app's card, for admins. Its run hook gets the workspace's settings, and the message of an error it throws is shown to the admin:

app.ts
import { SettingsButton } from "@examplary/apps";
 
app.addCapability(
  new SettingsButton({
    id: "sync",
    label: { en: "Sync now" },
    run: async (settings) => {
      await startSync(settings.apiToken);
    },
  }),
);