Coming in Broadcast 3.0. Until then, this page applies only to the Upcoming (edge) build.

Signal Recipes

Each recipe is a complete plugin. Copy the files, pack them as in Your First Plugin, and it works.

All recipes pack the same way:

zip -r -X ../my-plugin-1.0.0.broadcast-plugin manifest.json dist ui

A form that saves

This page loads saved values into a form, checks them on the server and shows any error under its field.

manifest.json:

{
  "id": "local.recipe-form",
  "name": "Recipe form",
  "version": "1.0.0",
  "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" },
  "ui": {
    "pages": [
      { "slot": "nav", "title": "Welcome note", "path": "ui/index.html" }
    ]
  }
}

dist/main.js:

export default definePlugin({
  async pageData({ input }, ctx) {
    if (input.action === "save") {
      const headline = String(input.values?.headline ?? "").trim();
      if (headline.length < 3) {
        return { errors: { headline: "Use at least 3 characters." } };
      }
      await ctx.store.set("note", {
        headline: headline.slice(0, 100),
        active: input.values.active === true,
      });
    }

    return { note: (await ctx.store.get("note")) ?? { headline: "", active: false } };
  },
});

ui/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Welcome note</title>
    <link rel="stylesheet" href="/plugin-kit/v1/kit.css">
    <script type="module" src="/plugin-kit/v1/kit.js"></script>
    <script type="module" src="app.js"></script>
  </head>
  <body>
    <sg-page title="Welcome note" width="narrow">
      <sg-card>
        <sg-form id="form">
          <sg-input name="headline" label="Headline" required maxlength="100"></sg-input>
          <sg-switch name="active" label="Show the note"></sg-switch>
          <sg-stack direction="row" justify="end">
            <sg-button type="submit" variant="primary">Save</sg-button>
          </sg-stack>
        </sg-form>
      </sg-card>
    </sg-page>
  </body>
</html>

ui/app.js:

const form = document.getElementById("form");

function fill(note) {
  form.querySelector('[name="headline"]').value = note.headline;
  form.querySelector('[name="active"]').value = note.active;
}

form.addEventListener("sg-submit", async (event) => {
  form.busy = true;
  try {
    const data = await sg.pageData({ action: "save", values: event.detail.values });
    if (data.errors) {
      form.errors = data.errors;
    } else {
      fill(data.note);
      sg.toast("Saved");
    }
  } catch (error) {
    sg.toast(`Not saved: ${error.message}`, { tone: "danger" });
  } finally {
    form.busy = false;
  }
});

const { note } = await sg.pageData();
fill(note);

Why it works. The server returns errors as data, with field names as keys, so form.errors can show each one. The finally block turns the button back on, also after a failure. A switch’s value is true or false, so the server compares it with === true.

A table with search and pagination

This page loads up to 100 subscribers once, then searches and pages in the browser.

manifest.json:

{
  "id": "local.recipe-table",
  "name": "Recipe table",
  "version": "1.0.0",
  "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" },
  "permissions": {
    "subscribers": "read"
  },
  "ui": {
    "pages": [
      { "slot": "nav", "title": "People", "path": "ui/index.html" }
    ]
  }
}

dist/main.js:

export default definePlugin({
  async pageData(request, ctx) {
    const { items } = await ctx.broadcast.subscribers.list({ limit: 100 });
    return {
      people: items.map((person) => ({
        id: person.id,
        email: person.email,
        name: [person.first_name, person.last_name].filter(Boolean).join(" "),
      })),
    };
  },
});

Send only the fields the page needs. This is why the function picks id, email and name.

ui/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>People</title>
    <link rel="stylesheet" href="/plugin-kit/v1/kit.css">
    <script type="module" src="/plugin-kit/v1/kit.js"></script>
    <script type="module" src="app.js"></script>
  </head>
  <body>
    <sg-page title="People" description="The first 100 subscribers in this channel.">
      <sg-search id="search" placeholder="Search by email or name"></sg-search>
      <sg-card flush>
        <sg-table id="people" empty-title="Nobody found" empty-text="Try another search."></sg-table>
      </sg-card>
      <sg-pagination id="pagination" page="1" pages="1"></sg-pagination>
    </sg-page>
  </body>
</html>

ui/app.js:

const PER_PAGE = 20;
const table = document.getElementById("people");
const pagination = document.getElementById("pagination");

let everyone = [];
let word = "";
let page = 1;

function show() {
  const found = everyone.filter((person) => `${person.email} ${person.name}`.toLowerCase().includes(word));
  const pages = Math.max(1, Math.ceil(found.length / PER_PAGE));
  page = Math.min(page, pages);

  const start = (page - 1) * PER_PAGE;
  table.rows = found.slice(start, start + PER_PAGE);

  // The pagination reads these only when it draws, so draw it again.
  pagination.setAttribute("page", page);
  pagination.setAttribute("pages", pages);
  pagination.draw();
}

table.columns = [
  { key: "email", label: "Email", sortable: true },
  { key: "name", label: "Name", sortable: true },
];

document.getElementById("search").addEventListener("sg-search", (event) => {
  word = event.detail.value.trim().toLowerCase();
  page = 1;
  show();
});

pagination.addEventListener("sg-page", (event) => {
  page = event.detail.page;
  show();
});

try {
  everyone = (await sg.pageData()).people;
  show();
} catch (error) {
  table.rows = [];
  sg.toast(`The page could not load: ${error.message}`, { tone: "danger" });
}

Why it works. One function, show, draws everything from three variables. Each change sets a variable and calls show. A new search goes back to page 1, and Math.min keeps the page in range.

The plugin reads the first 100 people. To read more, it needs the sensitive export permission, and it should search in pageData, one page at a time.

Delete with confirm

manifest.json:

{
  "id": "local.recipe-delete",
  "name": "Recipe delete",
  "version": "1.0.0",
  "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" },
  "ui": {
    "pages": [
      { "slot": "nav", "title": "Saved links", "path": "ui/index.html" }
    ]
  }
}

dist/main.js:

const STARTER = [
  { id: "docs", title: "Team handbook" },
  { id: "brand", title: "Brand guide" },
];

export default definePlugin({
  async pageData({ input }, ctx) {
    let links = (await ctx.store.get("links")) ?? STARTER;

    if (input.action === "delete") {
      links = links.filter((link) => link.id !== input.id);
      await ctx.store.set("links", links);
    }

    return { links };
  },
});

ui/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Saved links</title>
    <link rel="stylesheet" href="/plugin-kit/v1/kit.css">
    <script type="module" src="/plugin-kit/v1/kit.js"></script>
    <script type="module" src="app.js"></script>
  </head>
  <body>
    <sg-page title="Saved links" width="narrow">
      <sg-card flush>
        <sg-table id="links" empty-title="No links left"></sg-table>
      </sg-card>
    </sg-page>
  </body>
</html>

ui/app.js:

const table = document.getElementById("links");

async function remove(link, button) {
  const yes = await sg.confirm({
    title: `Delete ${link.title}?`,
    message: "You cannot undo this.",
    confirmLabel: "Delete link",
    danger: true,
  });
  if (!yes) return;

  button.busy = true;
  try {
    const data = await sg.pageData({ action: "delete", id: link.id });
    table.rows = data.links;
    sg.toast("Link deleted");
  } catch (error) {
    button.busy = false;
    sg.toast(`Not deleted: ${error.message}`, { tone: "danger" });
  }
}

function deleteButton(link) {
  const button = document.createElement("sg-button");
  button.setAttribute("size", "small");
  button.setAttribute("variant", "ghost");
  button.textContent = "Delete";
  button.addEventListener("click", () => remove(link, button));
  return button;
}

table.columns = [
  { key: "title", label: "Link" },
  { key: "actions", label: "", align: "right", render: deleteButton },
];

table.rows = (await sg.pageData()).links;

Why it works. button.busy = true stops a second click while the call runs. If the call fails, the button comes back. If it works, table.rows draws new rows and new buttons. The dialog names the thing, and the button says what it does.

A dashboard widget

This widget shows how many people signed up today.

manifest.json:

{
  "id": "local.recipe-widget",
  "name": "Recipe widget",
  "version": "1.0.0",
  "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" },
  "hooks": {
    "events": ["subscriber.created"]
  },
  "ui": {
    "pages": [
      { "slot": "dashboardWidget", "title": "Signups", "path": "ui/widget.html" }
    ]
  }
}

dist/main.js:

const today = () => new Date().toISOString().slice(0, 10);

export default definePlugin({
  async onEvent(event, ctx) {
    await ctx.store.increment(`signups:${today()}`);
  },

  async pageData(request, ctx) {
    return { today: (await ctx.store.get(`signups:${today()}`)) ?? 0 };
  },
});

ui/widget.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Signups</title>
    <link rel="stylesheet" href="/plugin-kit/v1/kit.css">
    <script type="module" src="/plugin-kit/v1/kit.js"></script>
    <script type="module" src="widget.js"></script>
  </head>
  <body>
    <sg-widget id="widget"><sg-skeleton lines="2"></sg-skeleton></sg-widget>
  </body>
</html>

ui/widget.js:

const widget = document.querySelector("#widget .sg-widget");

try {
  const data = await sg.pageData();
  const stat = document.createElement("sg-stat");
  stat.setAttribute("label", "Signups today");
  stat.setAttribute("value", data.today);
  widget.replaceChildren(stat);
} catch {
  widget.textContent = "Not available right now.";
}

Why it works. The skeleton shows until the data arrives, and then a stat takes its place. The code replaces the content of the inner .sg-widget box, which keeps the padding. Open the dashboard to see it. The title Signups comes from the manifest.

A subscriber panel

This panel keeps a short note about one person. The note lives in forSubscriber, so it is deleted with the person.

manifest.json:

{
  "id": "local.recipe-panel",
  "name": "Recipe panel",
  "version": "1.0.0",
  "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" },
  "ui": {
    "pages": [
      { "slot": "subscriberPanel", "title": "Staff note", "path": "ui/panel.html" }
    ]
  }
}

dist/main.js:

export default definePlugin({
  async pageData({ input }, ctx) {
    const person = ctx.store.forSubscriber(input.subscriber_id);

    if (input.action === "save") {
      await person.set("note", String(input.note ?? "").slice(0, 200));
    }

    return { note: (await person.get("note")) ?? "" };
  },
});

ui/panel.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Staff note</title>
    <link rel="stylesheet" href="/plugin-kit/v1/kit.css">
    <script type="module" src="/plugin-kit/v1/kit.js"></script>
    <script type="module" src="panel.js"></script>
  </head>
  <body>
    <sg-panel>
      <sg-form id="form">
        <sg-textarea name="note" label="Note about this person" maxlength="200" rows="2"></sg-textarea>
        <sg-button type="submit" size="small" variant="primary">Save</sg-button>
      </sg-form>
    </sg-panel>
  </body>
</html>

ui/panel.js:

const form = document.getElementById("form");
const subscriber_id = sg.subscriberId();

form.addEventListener("sg-submit", async (event) => {
  form.busy = true;
  try {
    await sg.pageData({ action: "save", subscriber_id, note: event.detail.values.note });
    sg.toast("Saved");
  } catch (error) {
    sg.toast(`Not saved: ${error.message}`, { tone: "danger" });
  } finally {
    form.busy = false;
  }
});

const data = await sg.pageData({ subscriber_id });
form.querySelector('[name="note"]').value = data.note;

Why it works. sg.subscriberId() reads the person from the frame’s address. The page sends it in each call, and forSubscriber refuses an ID from another channel. Open any subscriber to see the panel.

Last updated

Was this page helpful?

Thanks for your feedback!

Thanks for letting us know. We'll work on improving this page.