# Signal Recipes

> Complete Signal examples to copy: a form that saves, a table with search and pagination, delete with confirm, a dashboard widget and a subscriber panel.

Source: https://sendbroadcast.net/docs/signal-recipes

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

Each recipe is a complete plugin. Copy the files, pack them as in [Your First Plugin](https://sendbroadcast.net/docs/plugins-first-plugin), and it works.

All recipes pack the same way:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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