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

Signal Quick Start

In this tutorial you build Team notes, a page where a team keeps short notes. It has a primary button, a dialog with a form, a table, an empty state, and a delete step with a confirm dialog.

You need to know how to pack and upload a plugin. If you do not, do Your First Plugin first.

Step 1: Make the folders

mkdir -p team-notes/dist team-notes/ui
cd team-notes

At the end, you have these files:

team-notes/
  manifest.json
  dist/main.js
  ui/index.html
  ui/app.js

Step 2: Write the manifest

manifest.json:

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

ui.pages adds a page to the sidebar. The nav slot means a full page. This plugin needs no permissions, because it uses only its own storage.

Step 3: Write the server side

dist/main.js:

const MAX_TEXT = 500;

async function loadNotes(ctx) {
  const rows = await ctx.store.list("note:");
  return rows.map((row) => row.value).sort((a, b) => b.created_at.localeCompare(a.created_at));
}

export default definePlugin({
  async pageData({ input }, ctx) {
    if (input.action === "add") {
      const text = String(input.text ?? "").trim().slice(0, MAX_TEXT);
      if (!text) return { error: "Write a note first." };

      const id = crypto.randomUUID();
      await ctx.store.set(`note:${id}`, { id, text, created_at: new Date().toISOString() });
    }

    if (input.action === "delete") {
      await ctx.store.delete(`note:${input.id}`);
    }

    return { notes: await loadNotes(ctx) };
  },
});

pageData is the one function that the page calls. The page sends an action, the function does it, and it always returns the fresh list. It checks the input, because the input comes from a browser.

Your code runs on the server, in the sandbox. Your page runs in the browser. They talk only through pageData.

Step 4: Write the page

ui/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Team notes</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="Team notes" description="Short notes for everyone who works in this channel." width="narrow">
      <sg-button slot="actions" id="new" variant="primary" icon="plus">New note</sg-button>
      <sg-card flush>
        <sg-table id="notes" empty-title="No notes yet" empty-text="Click New note to add the first one."></sg-table>
      </sg-card>
    </sg-page>

    <sg-dialog id="dialog" title="New note">
      <sg-form id="form">
        <sg-textarea name="text" label="Note" required maxlength="500"></sg-textarea>
        <sg-stack direction="row" justify="end" gap="small">
          <sg-button id="cancel">Cancel</sg-button>
          <sg-button type="submit" variant="primary">Save note</sg-button>
        </sg-stack>
      </sg-form>
    </sg-dialog>
  </body>
</html>

Look at how little is here:

  • sg-page draws the title and the description. slot="actions" puts the button at the top right.
  • sg-card flush removes the card’s padding, so the table reaches the edges.
  • sg-table draws itself, and empty-title sets its empty state.
  • sg-dialog stays hidden until you open it.

Step 5: Write the script

ui/app.js:

const table = document.getElementById("notes");
const dialog = document.getElementById("dialog");
const form = document.getElementById("form");

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

table.columns = [
  { key: "text", label: "Note" },
  { key: "created_at", label: "Added", sortable: true, render: (note) => note.created_at.slice(0, 10) },
  { key: "actions", label: "", align: "right", render: deleteButton },
];

async function load(input = {}) {
  try {
    const data = await sg.pageData(input);
    if (data.notes) table.rows = data.notes;
    return data;
  } catch (error) {
    table.rows = [];
    sg.toast(`The page could not load: ${error.message}`, { tone: "danger" });
    return null;
  }
}

async function removeNote(note) {
  const yes = await sg.confirm({
    title: "Delete this note?",
    message: note.text,
    confirmLabel: "Delete note",
    danger: true,
  });
  if (!yes) return;

  await load({ action: "delete", id: note.id });
  sg.toast("Note deleted");
}

document.getElementById("new").addEventListener("click", () => dialog.open());
document.getElementById("cancel").addEventListener("click", () => dialog.close());

form.addEventListener("sg-submit", async (event) => {
  form.busy = true;
  const data = await load({ action: "add", text: event.detail.values.text });
  form.busy = false;

  if (!data) return;
  if (data.error) {
    form.errors = { text: data.error };
    return;
  }

  form.reset();
  dialog.close();
  sg.toast("Note saved");
});

await load();

Read it from the top:

  1. table.columns says what to show. render lets a column show any element, here a Delete button.
  2. load calls sg.pageData, and it puts the notes in the table. Setting table.rows redraws the table.
  3. If the call fails, load sets rows to an empty list. Without this, the table shows its loading state forever.
  4. removeNote asks first, with sg.confirm. window.confirm does not work in the sandbox.
  5. The form sends sg-submit only when the browser’s checks pass. So a required field is checked for you.
  6. form.busy = true turns off the submit button while the call runs. So a double click cannot save twice.
  7. form.errors shows an error from the server under its field.

Step 6: Pack and install

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

Upload the file, deploy it and turn it on in a channel, as in Your First Plugin.

Step 7: See it work

  1. Open Plugins in the sidebar, then Team notes.

You should see the title, the blue New note button and the text No notes yet.

  1. Click New note, then Save note with the box empty.

You should see an error under the box. The dialog stays open.

  1. Type a note and click Save note.

The dialog closes, a small message says Note saved, and the note is in the table.

  1. Click Delete on the row, then Delete note.

The table shows No notes yet again.

If it goes wrong

What you see Reason
Plain text with no style, and no title kit.css or kit.js did not load. Check the two lines in <head>
sg is not defined in the console Your script runs before kit.js. Put kit.js first
The table shows grey bars forever rows was never set. Set it also when a call fails
Error: plugin_error in a message Your pageData threw. Read the plugin’s logs
The change you made does not show Files are kept for 5 minutes. Release a new version

Next steps

Last updated

Was this page helpful?

Thanks for your feedback!

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