# Signal Quick Start

> Build a full Broadcast plugin page with Signal, from an empty folder. Every file in full, with a form, a table, a dialog and a delete step.

Source: https://sendbroadcast.net/docs/signal-quick-start

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

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](https://sendbroadcast.net/docs/plugins-first-plugin) first.

## Step 1: Make the folders

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

At the end, you have these files:

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

## Step 2: Write the manifest

`manifest.json`:

```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`:

```javascript
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`:

```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`:

```javascript
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

```bash
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](https://sendbroadcast.net/docs/plugins-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**.

2. Click **New note**, then **Save note** with the box empty.

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

3. Type a note and click **Save note**.

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

4. 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 `` |
| `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

- [Signal Recipes](https://sendbroadcast.net/docs/signal-recipes): forms, search, pagination, a widget and a panel.
- [Signal Forms](https://sendbroadcast.net/docs/signal-forms) and [Signal Data](https://sendbroadcast.net/docs/signal-data): every option of what you used here.
