Talking to the outbound quick edit modal from a document template

The Quick edit modal lets users edit an order’s outbound lines in a table: line number, quantity, unit price, discount, free units and line text. When the order type has a document template set for quick edit, the modal splits into two columns and shows that template on the right. The template is an ordinary HTML document template, rendered for the order and loaded in an iframe. A typical use is to show inventory or availability for the SKUs on the order while the user edits the sales side.

The template and the modal can send each other messages:

  • The modal tells the template when lines have been saved, including the saved values, so the template can update what it shows.
  • The template asks the modal to reload one SKU, several SKUs or all of them, for example after the template changed the order itself.

Saving in the modal does not reload the template. The template receives a message and decides how to react: ignore it, update part of the page, or reload itself.

Note: The template is never reloaded behind your back, so anything the user has scrolled to, expanded or typed into the template stays as it is. With a template shown, the modal also stays open after a successful save, so the user can keep working through the order.

Before you start

You need:

  1. The powerpack (the document templates extension) on the team.
  2. An active document template with model Order and output type HTML.
  3. That template chosen in the order type’s settings, under Lot lines, outbound → Document template shown beside the quick edit table. The setting only appears with the powerpack, and only lists active HTML templates on the Order model.

The quick edit modal is available on sales and supplier return orders. Open an order of one of those types, go to the outbound lots and click Quick edit (BETA). The template appears on the right.

How the messages work

The two sides use the browser’s standard window.postMessage API. The template is served from the same address as tracezilla itself, so both sides can check that a message really came from the other.

1
2
3
4
5
6
7
8
┌───────────── Quick edit modal ────────────┬──── Template (iframe) ────┐
│                                           │                           │
│  user saves lines  ───────────────────────┼─▶ tracezilla:quick-edit:  │
│                                           │   saved                   │
│                                           │                           │
│  reloads the requested     ◀──────────────┼── tracezilla:quick-edit:  │
│  SKUs in the table                        │   refresh                 │
└───────────────────────────────────────────┴───────────────────────────┘

Every message is a plain object with a type field. Messages of any other type are ignored by both sides.

Lines and SKUs

The table has one row per SKU, but a SKU can appear in two rows: one for its traceable quantity and one for its non-traceable quantity. Each row has a traceable flag.

Line number, line text and discount belong to the SKU, not the row, so both rows of a SKU always share them. Quantity, unit price and free units are per row.

Message reference

tracezilla:quick-edit:saved (modal → template)

Sent after every save, including a partial one where some rows failed.

Field Type Description
type string Always tracezilla:quick-edit:saved.
order_id string ID of the order.
sku_ids array of strings SKUs with at least one row that saved. Empty if every row failed.
failed_sku_ids array of strings SKUs with at least one row that failed to save. Those rows are marked in the table with the error.
lines array of objects One entry per saved row. See below.

Each entry in lines:

Field Type Description
sku_id string ID of the SKU.
traceable boolean Whether this is the SKU’s traceable row.
quantity number Quantity in the row’s unit, e.g. colli for traceable rows. Taken from the server after the save.
quantity_uom number Quantity in the SKU’s stock unit, e.g. kg. Taken from the server after the save.
unit_price number Unit price, in the unit given by price_per.
price_per string What the price is per: stock_unit, lot_unit, parcel_unit or custom_unit.
uoms_per_price_unit number Stock units in one price unit. unit_price / uoms_per_price_unit is the price per stock unit.
free_units number Free units included in the quantity. Taken from the server after the save.
line_number number or null The SKU’s line number.
line_text string The SKU’s line text.
discount_percentage number The SKU’s discount. Always 0 on supplier returns, which have no discount column.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "type": "tracezilla:quick-edit:saved",
  "order_id": "9b2c6f0e-4a1d-4c5e-9f7a-2d3e4f5a6b7c",
  "sku_ids": ["be3233c4-c0a2-4f47-a62a-303492c20ba6"],
  "failed_sku_ids": [],
  "lines": [
    {
      "sku_id": "be3233c4-c0a2-4f47-a62a-303492c20ba6",
      "traceable": true,
      "quantity": 12,
      "quantity_uom": 60,
      "unit_price": 89.5,
      "price_per": "lot_unit",
      "uoms_per_price_unit": 5,
      "free_units": 1,
      "line_number": 10,
      "line_text": "Deliver chilled",
      "discount_percentage": 5
    }
  ]
}

Note: Quantity, stock-unit quantity and free units come back from the server, so they reflect what was actually stored. The other values are what was in the table when the user saved.

tracezilla:quick-edit:refresh (template → modal)

Asks the modal to reload rows from the server.

Field Type Description
type string Always tracezilla:quick-edit:refresh.
sku_id string Optional. Reload this one SKU.
sku_ids array of strings Optional. Reload these SKUs. Takes precedence over sku_id.

Leave out both sku_id and sku_ids to reload every SKU.

What the modal does with it:

  • Never overwrites unsaved edits. A SKU with unsaved changes in any of its rows, or a save in progress, is skipped entirely. This includes a new row where the user has picked the SKU but not saved yet. The user’s edits stay, and the next save wins.
  • Updates the requested SKUs in place: quantity, price and free units per row, plus the SKU’s line number, line text and discount.
  • Adds rows for lines that are new on the order, above any blank rows at the bottom of the table.
  • Removes rows for lines no longer on the order.
  • Leaves blank new rows alone.
  • Reloads the order’s line table when it closes, so the order view behind the modal shows the change too.

Unlike the select lots modal, a refresh works without a pickup location or date on the order.

Receiving: react to a save

Add a script to the template’s body. Always check both where the message came from and its type before acting on it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script>
  window.addEventListener("message", function (evt) {
    // Only accept messages from tracezilla, sent by the window around us.
    if (evt.origin !== window.location.origin || evt.source !== window.parent) {
      return;
    }

    var message = evt.data;

    if (!message || message.type !== "tracezilla:quick-edit:saved") {
      return;
    }

    // Nothing was saved, e.g. every row failed.
    if (message.sku_ids.length === 0) {
      return;
    }

    // Simplest possible reaction: render the template again.
    window.location.reload();
  });
</script>

Reloading the whole template is fine to start with. To keep the user’s place in a long template, update only the SKUs that changed instead. See Updating only the changed SKUs.

Using the saved values directly

Because lines carries the saved values, some updates need no round trip to the server at all. For example, to show each SKU’s ordered quantity next to its availability:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<div data-sku-id="{{ sku_id }}">
  Ordered: <span class="ordered-quantity">…</span>
</div>

<script>
  window.addEventListener("message", function (evt) {
    if (evt.origin !== window.location.origin || evt.source !== window.parent) {
      return;
    }

    if (!evt.data || evt.data.type !== "tracezilla:quick-edit:saved") {
      return;
    }

    // Add up traceable and non-traceable rows per SKU.
    var orderedUoms = {};

    evt.data.lines.forEach(function (line) {
      orderedUoms[line.sku_id] =
        (orderedUoms[line.sku_id] || 0) + line.quantity_uom;
    });

    Object.keys(orderedUoms).forEach(function (skuId) {
      var cell = document.querySelector(
        '[data-sku-id="' + skuId + '"] .ordered-quantity',
      );

      if (cell) {
        cell.textContent = orderedUoms[skuId];
      }
    });
  });
</script>

Warning: lines only holds the rows that were saved. If a SKU has a traceable and a non-traceable row and only one changed, only that one is in lines. For totals that need every row, fetch the template again as described below.

Sending: ask the modal to reload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script>
  function refreshQuickEdit(skuIds) {
    // Opened on its own (not inside the modal), there's no one to tell.
    if (window.parent === window) {
      return;
    }

    var message = { type: "tracezilla:quick-edit:refresh" };

    if (skuIds && skuIds.length > 0) {
      message.sku_ids = skuIds;
    }

    window.parent.postMessage(message, window.location.origin);
  }

  // One SKU:  refreshQuickEdit(['be3233c4-c0a2-4f47-a62a-303492c20ba6'])
  // All SKUs: refreshQuickEdit()
</script>

Always pass window.location.origin as the second argument. It makes sure the message only goes to tracezilla, never to another site.

Updating only the changed SKUs

The template is rendered on the server by Liquid, so the easiest way to get fresh data is to ask the server for the same template again and swap in the parts that changed. The template’s own address already works for that.

  1. Wrap each SKU’s section in an element with a data-sku-id attribute.
  2. When a save message arrives, fetch the template again in the background.
  3. Replace the sections of the SKUs in sku_ids.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<div data-sku-id="{{ sku_id }}">
  <!-- whatever the template shows for this SKU -->
</div>

<script>
  function replaceSkuSections(skuIds) {
    fetch(window.location.href, { credentials: "same-origin" })
      .then(function (response) {
        return response.text();
      })
      .then(function (html) {
        var fresh = new DOMParser().parseFromString(html, "text/html");

        document.querySelectorAll("[data-sku-id]").forEach(function (section) {
          var skuId = section.getAttribute("data-sku-id");

          if (skuIds.indexOf(skuId) === -1) {
            return;
          }

          var replacement = fresh.querySelector(
            '[data-sku-id="' + skuId + '"]',
          );

          if (replacement) {
            section.replaceWith(replacement);
          } else {
            // The SKU is no longer on the order.
            section.remove();
          }
        });
      });
  }

  window.addEventListener("message", function (evt) {
    if (evt.origin !== window.location.origin || evt.source !== window.parent) {
      return;
    }

    if (!evt.data || evt.data.type !== "tracezilla:quick-edit:saved") {
      return;
    }

    if (evt.data.sku_ids.length > 0) {
      replaceSkuSections(evt.data.sku_ids);
    }
  });
</script>

In the first line, `` stands for whichever Liquid variable holds the SKU ID where your template loops over its SKUs. Use the SKU’s ID, not its SKU code: the messages always carry IDs.

Note: SKUs newly added in the quick edit table have no section on the page yet, so they won’t appear with this approach. Fall back to window.location.reload() when sku_ids contains a SKU the page has no section for.

Complete example

A minimal template that lists the order’s SKUs, updates them after every save, adds new SKUs by reloading, and has a button that asks the modal to reload one SKU.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<h3>Order {{ order.prefixed_number }}</h3>

<!-- Loop over your SKU data here, one section per SKU. -->
<div data-sku-id="{{ sku_id }}">
  <strong>{{ sku_code }}</strong>
  <button type="button" onclick="refreshQuickEdit(['{{ sku_id }}'])">
    Reload in quick edit
  </button>
</div>

<script>
  var embedded = window.parent !== window;

  function refreshQuickEdit(skuIds) {
    if (!embedded) {
      return;
    }

    var message = { type: "tracezilla:quick-edit:refresh" };

    if (skuIds && skuIds.length > 0) {
      message.sku_ids = skuIds;
    }

    window.parent.postMessage(message, window.location.origin);
  }

  function replaceSkuSections(skuIds) {
    fetch(window.location.href, { credentials: "same-origin" })
      .then(function (response) {
        return response.text();
      })
      .then(function (html) {
        var fresh = new DOMParser().parseFromString(html, "text/html");

        document.querySelectorAll("[data-sku-id]").forEach(function (section) {
          var skuId = section.getAttribute("data-sku-id");

          if (skuIds.indexOf(skuId) === -1) {
            return;
          }

          var replacement = fresh.querySelector(
            '[data-sku-id="' + skuId + '"]',
          );

          if (replacement) {
            section.replaceWith(replacement);
          } else {
            section.remove();
          }
        });
      });
  }

  window.addEventListener("message", function (evt) {
    if (evt.origin !== window.location.origin || evt.source !== window.parent) {
      return;
    }

    var message = evt.data;

    if (!message || message.type !== "tracezilla:quick-edit:saved") {
      return;
    }

    if (message.sku_ids.length === 0) {
      return;
    }

    // A SKU added in the table has no section here yet: render it all again.
    var hasNewSku = message.sku_ids.some(function (skuId) {
      return !document.querySelector('[data-sku-id="' + skuId + '"]');
    });

    if (hasNewSku) {
      window.location.reload();
    } else {
      replaceSkuSections(message.sku_ids);
    }
  });
</script>

The save shortcut

Ctrl+S (⌘S on Mac) saves the quick edit table, also while focus is inside the template. The modal listens for the shortcut in the template too and stops the browser’s own “Save page” dialog. Don’t bind Ctrl+S in your template, because the modal will handle it first.

Testing

  • Open it in the modal. Messages only flow when the template is shown inside the quick edit modal. Opened in its own tab, the template has no parent to talk to. The window.parent === window check makes sending a no-op there.
  • Watch the messages. In the browser’s developer tools, switch the console to the iframe’s context and add a temporary listener:

    1
    2
    3
    
    window.addEventListener("message", (evt) =>
      console.log(evt.origin, evt.data),
    );
    

    Edit a line in the table, save, and the save message is logged.

  • Send a refresh by hand. In the iframe’s console context, run:

    1
    2
    3
    4
    
    window.parent.postMessage(
      { type: "tracezilla:quick-edit:refresh" },
      window.location.origin,
    );
    

    The modal reloads every SKU that has no unsaved edits.

Troubleshooting

Problem Likely cause
There’s no Quick edit button. The order isn’t a sales or supplier return order.
The template doesn’t show in the modal. The setting is empty, the template isn’t active, it isn’t an HTML template on the Order model, or the team doesn’t have the powerpack.
The template never receives a save message. The listener checks evt.source against something other than window.parent, or compares type with a typo. Log every message to check.
A refresh request does nothing. type has a typo, or the message was sent without window.location.origin.
A refresh didn’t update a SKU. That SKU has unsaved edits in the table, or a new row for it that hasn’t been saved. Refreshes never overwrite unsaved edits.
sku_ids is empty. Every row in the save failed. The failed SKUs are in failed_sku_ids.
A SKU’s total in lines looks too low. lines only holds saved rows. The SKU’s other row (traceable or non-traceable) didn’t change, so it isn’t included.

See also

The Select lots modal works the same way, with its own message types tracezilla:select-lots:saved and tracezilla:select-lots:refresh. See Select lots template events.