← All recipes

Purchase order

Build a purchase order whose supplier and delivery terms live in fixed cells. You learn hand-built grid tables, merged cells, per-cell padding and alignment, and how to hide a whole row when a value is missing.

Use this template

What this teaches

  • table-grid
  • cell-merge
  • cell-pad
  • cell-head
  • cell-bg
  • node-show
  • style-valign
  • node-line
  • page-margin
  • expr-format-date
  • expr-ternary

Building it in the editor

  1. Add a table and choose the hand-built mode at the top of the table properties. Set it to 6 rows and 4 columns.
  2. The two left columns are label columns, so make them narrow. Drag a column boundary to resize; the widths always keep adding up to the table width.
  3. Click a cell on the canvas to select it. Type its text, and turn the label cells into header cells so the header background applies to them.
  4. A long value such as an address needs more room. Select that cell and press merge with the cell on the right to make one wider cell.
  5. Put bindings such as {{ data.supplier }} into the value cells. For a date, {{ format(data.due, "date", "long", "en-US") }} renders it the way people read it.
  6. If a cell feels cramped, give that cell more padding. Selecting a cell hides the whole-table padding and shows only this cell — the screen tells you what you are editing.
  7. For a row that only appears sometimes, put {{ data.note }} in the "show when" box of the object that holds it. With no value the row is left out entirely, leaving no gap.
  8. Add a line above the signature area. A shape only needs a colour and a thickness; there is no reason to build it as a table.

Building it with the schema and API

  1. A grid table leaves props.bind empty. Instead rows gives the row count and cells lists the cells.
  2. Only list the cells you use — a missing position is simply an empty cell, so you never have to write all 24.
  3. span merges to the right and rowspan merges downward. Do not also list a cell for a position that has been merged over.
  4. Setting head to true makes it a header cell, and the table fill colour is painted only on those.
  5. A node show is an expression. When the value is missing or empty, that node and everything inside it is left out of the output.

The finished document

The JSON below is exactly the document those two routes produce. Our tests validate it and check that it renders without warnings, so you can paste it and use it as is.

EXAMPLE
{
  "v": 1,
  "unit": "px",
  "page": {
    "size": "a4",
    "orientation": "portrait",
    "margin": {
      "t": 56,
      "r": 48,
      "b": 56,
      "l": 48
    },
    "header": "",
    "footer": ""
  },
  "fonts": [],
  "styles": [
    {
      "id": "s_h1",
      "fontFamily": "Liberation Sans",
      "fontSize": 24,
      "fontWeight": 700,
      "italic": false,
      "color": "#111111",
      "bg": "transparent",
      "align": "left",
      "valign": "top",
      "lineHeight": 1.5,
      "border": "none",
      "radius": 0,
      "opacity": 1
    },
    {
      "id": "s_tbl",
      "fontFamily": "Liberation Sans",
      "fontSize": 12,
      "fontWeight": 400,
      "italic": false,
      "color": "#111111",
      "bg": "transparent",
      "align": "left",
      "valign": "middle",
      "lineHeight": 1.4,
      "border": "none",
      "radius": 0,
      "opacity": 1
    },
    {
      "id": "s_note",
      "fontFamily": "Liberation Sans",
      "fontSize": 11,
      "fontWeight": 400,
      "italic": false,
      "color": "#555555",
      "bg": "transparent",
      "align": "left",
      "valign": "top",
      "lineHeight": 1.5,
      "border": "none",
      "radius": 0,
      "opacity": 1
    }
  ],
  "nodes": [
    {
      "id": "n_title",
      "parent": "",
      "order": 0,
      "type": "label",
      "x": 0,
      "y": 0,
      "w": 697,
      "h": 32,
      "show": "",
      "styleId": "s_h1",
      "props": {
        "text": "Purchase order",
        "src": "",
        "fit": "",
        "thickness": 0,
        "stroke": "",
        "fill": "",
        "repeat": "",
        "as": "",
        "break": "",
        "keep": "",
        "padding": 0,
        "padY": 0,
        "padX": 0,
        "align": "",
        "valign": "",
        "wrap": "",
        "bind": "",
        "headerHeight": 0,
        "rowHeight": 0,
        "max": 0,
        "columns": [],
        "rows": 0,
        "cells": [],
        "chartKind": "",
        "codeKind": "",
        "ecc": "",
        "fieldKind": "",
        "fieldName": "",
        "options": [],
        "required": false,
        "readonly": false,
        "html": ""
      },
      "name": "Title"
    },
    {
      "id": "n_head",
      "parent": "",
      "order": 1,
      "type": "table",
      "x": 0,
      "y": 52,
      "w": 697,
      "h": 120,
      "show": "",
      "styleId": "s_tbl",
      "props": {
        "text": "",
        "src": "",
        "fit": "",
        "thickness": 1,
        "stroke": "#c9ced6",
        "fill": "#f3f5f7",
        "repeat": "",
        "as": "",
        "break": "",
        "keep": "",
        "padding": 0,
        "padY": 7,
        "padX": 9,
        "align": "",
        "valign": "middle",
        "wrap": "",
        "bind": "",
        "headerHeight": 0,
        "rowHeight": 30,
        "max": 0,
        "columns": [
          {
            "header": "",
            "cell": "",
            "width": 110,
            "align": "left"
          },
          {
            "header": "",
            "cell": "",
            "width": 238,
            "align": "left"
          },
          {
            "header": "",
            "cell": "",
            "width": 110,
            "align": "left"
          },
          {
            "header": "",
            "cell": "",
            "width": 239,
            "align": "left"
          }
        ],
        "rows": 4,
        "cells": [
          {
            "r": 0,
            "c": 0,
            "span": 1,
            "rowspan": 1,
            "text": "Supplier",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": true,
            "border": ""
          },
          {
            "r": 0,
            "c": 1,
            "span": 1,
            "rowspan": 1,
            "text": "{{ data.supplier }}",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": false,
            "border": ""
          },
          {
            "r": 0,
            "c": 2,
            "span": 1,
            "rowspan": 1,
            "text": "PO number",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": true,
            "border": ""
          },
          {
            "r": 0,
            "c": 3,
            "span": 1,
            "rowspan": 1,
            "text": "{{ data.no }}",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": false,
            "border": ""
          },
          {
            "r": 1,
            "c": 0,
            "span": 1,
            "rowspan": 1,
            "text": "Ship to",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": true,
            "border": ""
          },
          {
            "r": 1,
            "c": 1,
            "span": 3,
            "rowspan": 1,
            "text": "{{ data.shipTo }}",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": 12,
            "src": "",
            "fit": "",
            "bg": "",
            "head": false,
            "border": ""
          },
          {
            "r": 2,
            "c": 0,
            "span": 1,
            "rowspan": 1,
            "text": "Due",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": true,
            "border": ""
          },
          {
            "r": 2,
            "c": 1,
            "span": 1,
            "rowspan": 1,
            "text": "{{ format(data.due, \"date\", \"long\", \"en-US\") }}",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": false,
            "border": ""
          },
          {
            "r": 2,
            "c": 2,
            "span": 1,
            "rowspan": 1,
            "text": "Terms",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": true,
            "border": ""
          },
          {
            "r": 2,
            "c": 3,
            "span": 1,
            "rowspan": 1,
            "text": "{{ data.terms }}",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": false,
            "border": ""
          },
          {
            "r": 3,
            "c": 0,
            "span": 1,
            "rowspan": 1,
            "text": "Contact",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": true,
            "border": ""
          },
          {
            "r": 3,
            "c": 1,
            "span": 3,
            "rowspan": 1,
            "text": "{{ data.contact }}",
            "align": "left",
            "valign": "",
            "wrap": "",
            "pad": -1,
            "padY": -1,
            "padX": -1,
            "src": "",
            "fit": "",
            "bg": "",
            "head": false,
            "border": ""
          }
        ],
        "chartKind": "",
        "codeKind": "",
        "ecc": "",
        "fieldKind": "",
        "fieldName": "",
        "options": [],
        "required": false,
        "readonly": false,
        "html": ""
      },
      "name": "Order details"
    },
    {
      "id": "n_note",
      "parent": "",
      "order": 2,
      "type": "label",
      "x": 0,
      "y": 188,
      "w": 697,
      "h": 20,
      "show": "{{ data.note }}",
      "styleId": "s_note",
      "props": {
        "text": "Note: {{ data.note }}",
        "src": "",
        "fit": "",
        "thickness": 0,
        "stroke": "",
        "fill": "",
        "repeat": "",
        "as": "",
        "break": "",
        "keep": "",
        "padding": 0,
        "padY": 0,
        "padX": 0,
        "align": "",
        "valign": "",
        "wrap": "",
        "bind": "",
        "headerHeight": 0,
        "rowHeight": 0,
        "max": 0,
        "columns": [],
        "rows": 0,
        "cells": [],
        "chartKind": "",
        "codeKind": "",
        "ecc": "",
        "fieldKind": "",
        "fieldName": "",
        "options": [],
        "required": false,
        "readonly": false,
        "html": ""
      },
      "name": "Note"
    },
    {
      "id": "n_rule",
      "parent": "",
      "order": 3,
      "type": "line",
      "x": 397,
      "y": 256,
      "w": 300,
      "h": 2,
      "show": "",
      "styleId": "s_note",
      "props": {
        "text": "",
        "src": "",
        "fit": "",
        "thickness": 1,
        "stroke": "#111111",
        "fill": "",
        "repeat": "",
        "as": "",
        "break": "",
        "keep": "",
        "padding": 0,
        "padY": 0,
        "padX": 0,
        "align": "",
        "valign": "",
        "wrap": "",
        "bind": "",
        "headerHeight": 0,
        "rowHeight": 0,
        "max": 0,
        "columns": [],
        "rows": 0,
        "cells": [],
        "chartKind": "",
        "codeKind": "",
        "ecc": "",
        "fieldKind": "",
        "fieldName": "",
        "options": [],
        "required": false,
        "readonly": false,
        "html": ""
      },
      "name": "Signature rule"
    },
    {
      "id": "n_sign",
      "parent": "",
      "order": 4,
      "type": "label",
      "x": 397,
      "y": 262,
      "w": 300,
      "h": 18,
      "show": "",
      "styleId": "s_note",
      "props": {
        "text": "{{ data.approver ? data.approver : \"(awaiting approval)\" }}",
        "src": "",
        "fit": "",
        "thickness": 0,
        "stroke": "",
        "fill": "",
        "repeat": "",
        "as": "",
        "break": "",
        "keep": "",
        "padding": 0,
        "padY": 0,
        "padX": 0,
        "align": "",
        "valign": "",
        "wrap": "",
        "bind": "",
        "headerHeight": 0,
        "rowHeight": 0,
        "max": 0,
        "columns": [],
        "rows": 0,
        "cells": [],
        "chartKind": "",
        "codeKind": "",
        "ecc": "",
        "fieldKind": "",
        "fieldName": "",
        "options": [],
        "required": false,
        "readonly": false,
        "html": ""
      },
      "name": "Approver"
    }
  ]
}

The request body. Put the document above into template and the data below into data.

REQUEST
{
  "template": "(the document above)",
  "data": {
    "supplier": "Hanbit Trading Ltd.",
    "no": "PO-2026-0117",
    "shipTo": "118 Seongsui-ro, Seongdong-gu, Seoul — 3F warehouse",
    "due": "2026-09-15",
    "terms": "Net 30 after delivery",
    "contact": "Do-hyun Kim · +82 10 0000 0000",
    "note": "Fragile — please load by pallet.",
    "approver": ""
  }
}