LIGHTWEIGHT TEMPLATES
Not to be confused with Events, which cover DOM handlers inside a template (onClick, onInput, etc.). This page is about events the template sends out of the widget.
What an external event is
An external event is a named notification a template sends out of the widget when the user interacts with it, optionally carrying a bag of parameters. Firing one does two things:
-
logs an auto micro conversion with event type
EXTERNAL_EVENTand the event name as its event group id; this is what appears in the Conversion Report; -
calls the publisher's
customEventhandler on the host page with the event name and the params object.
tp.push(["addHandler", "customEvent", function (event) {
event.eventName; // the name you passed
event.params; // the params object you passed
}]);
This is the same customEvent handler classic templates used, so an existing publisher integration keeps receiving events after a migration. What changes is the shape of params; see Coming from classic templates below.
What can fire one
|
Mechanism |
Use it when |
Documented in |
|---|---|---|
|
|
you need to fire an event from arbitrary markup, a plain |
|
|
|
you are already using a Piano button component |
Both are current, and both are the same mechanism underneath. Pick whichever fits the markup you already have. There is no third way: the classic external-event-* HTML attributes do nothing in a Lightweight template, whether written in the JSX tab or set from the JS tab with setAttribute().
Buttons that accept externalEventName / externalEventParams
-
PianoCloseButton (and the compound CloseButton)
PianoExternalEvent
|
Attribute name |
type |
required / optional |
default value |
description |
|---|---|---|---|---|
|
|
string |
required |
|
external event name |
|
|
object |
optional |
|
parameters sent with the event |
|
|
JSX.Element |
required |
|
the markup whose clicks fire the event |
|
|
N/A |
optional |
|
applied to the wrapper element |
PianoExternalEvent renders a wrapper <div> around its children and listens for clicks on it in the capture phase. Two consequences worth knowing up front:
Any click inside the wrapper fires the event, not only a click on the button; keep the wrapper tight around the element you mean.
<PianoExternalEvent name="cta-click" style={{ display: "contents" }}>
<button type="submit">Continue</button>
</PianoExternalEvent>
Example 1: static parameters
Everything is known at authoring time, so only the JSX tab is involved.
<PianoExternalEvent
name="cta-click"
params={{ placement: "sidebar", campaign: "[%% campaign_id %%]" }}
>
<button type="button">Continue</button>
</PianoExternalEvent>
Example 2: parameters read from the form at click time
This is the case that needs both tabs. The JSX tab declares the markup and the params; the JS tab declares the variables the markup binds to and any helpers the params call. Two kinds of binding appear here: ref, when the value is read straight off the DOM at fire time, and a signal, when the template has to render the value as well.
JS tab
// read off the DOM at fire time
let comment;
let topicA;
let topicB;
// a signal, because the subject is rendered as well as sent
const [subject, setSubject] = createSignal("");
const selectedTopics = () =>
[
topicA?.checked ? "[%% topic_a_id %%]" : "",
topicB?.checked ? "[%% topic_b_id %%]" : "",
]
.filter(Boolean)
.join(",");
JSX tab
<form>
<label for="comment">Comment</label>
<input id="comment" type="text" ref={comment} required />
<label>
<input type="checkbox" ref={topicA} /> Topic A
</label>
<label>
<input type="checkbox" ref={topicB} /> Topic B
</label>
<label for="subject">Subject</label>
<input
id="subject"
type="text"
maxlength="60"
onInput={(e) => setSubject(e.currentTarget.value)}
/>
<p>{subject().length} / 60</p>
<PianoExternalEvent
name="[%% event_name %%]"
params={{
comment: comment?.value,
topicIds: selectedTopics(),
subject: subject(),
formId: "[%% form_id %%]",
}}
style={{ display: "contents" }}
>
<button type="submit">Send</button>
</PianoExternalEvent>
</form>
How the two tabs connect
-
The JS tab is the component body. Your JS tab and the JSX tab are compiled into the same function, in that order. Anything you declare at the top level of the JS tab is visible to the JSX; anything you hide inside an IIFE is not. FAQ: How does the JS tab interact with the JSX tab?
JavaScript// ✅ visible to the JSX let comment; // ❌ invisible to the JSX (function () { let comment; })(); -
ref={x}assigns the DOM element tox. It is a plain assignment, not a signal setter, soxmust be declared withletorvarat the top level of the JS tab.constthrows at render time; an undeclared name is a silent global. FAQ: What is ref and how do I use it? and Which variables from the JS tab can the JSX see, and how do I declare them? -
A signal is the other option, and the one to pick when the value also drives rendering; the character count above cannot be written with a
ref.createSignaland the rest of the Solid primitives are already in scope in the JS tab, so no import is needed. -
paramsis evaluated when the event fires, not when the template renders. That is what makescomment?.value,subject()andselectedTopics()return what the user actually typed and picked. You do not need to mirror form state into variables on everyonChange; read it insideparams. -
[%% field %%]content fields are substituted in the JS tab too. Substitution runs over all three tabs, HTML/JSX, CSS and JS, before the template reaches the browser, and the editor offers content-field autocompletion in all three. FAQ: Are content fields substituted in the JS tab too?
Buttons
A Piano button fires the event on its own click, before your onClick runs. No wrapper element is added.
<PianoPrimaryButton
onClick={(e) => context.login()}
externalEventName="header-login"
externalEventParams={{ placement: "header" }}
>
<T>Log in</T>
</PianoPrimaryButton>
Runtime parameters work exactly as they do for PianoExternalEvent; externalEventParams is evaluated at click time, so it can read live DOM values bound with ref:
JS tab
let promoCode;
JSX tab
<input type="text" ref={promoCode} placeholder="Promo code" />
<PianoPrimaryButton
onClick={(e) => context.startCheckout({ term: item })}
externalEventName="promo-applied"
externalEventParams={{ code: promoCode?.value, termId: item.termId }}
>
<T>Subscribe</T>
</PianoPrimaryButton>
Note. The example above uses PianoPrimaryButton, but externalEventName and externalEventParams behave identically on every button listed above: PianoPrimaryButton, PianoSecondaryButton, PianoGhostButton, PianoIconButton, PianoCloseButton / CloseButton, PianoBackButton, PianoStartCheckoutButton and PianoBuyAsAGiftButton.
Two of them add a parameter of their own: PianoStartCheckoutButton and PianoBuyAsAGiftButton merge termId (taken from their term prop) into externalEventParams, so you do not have to pass it yourself.
Coming from classic templates
In a classic (AngularJS) template, an external event was an attribute directive:
<button external-event="cta-click"
external-event-event-label="sidebar"
external-event-plan="{{ selectedPlan }}">
Continue
</button>
The classic runtime read every attribute whose name began with external-event-, turned the dash-separated suffix into a camelCase key with a lowercased first letter, and used the attribute's (interpolated) string value:
|
Classic attribute |
params key |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Because these were HTML attributes, every classic parameter was a string, and the browser lowercases attribute names, so external-event-eventLabel produced the key eventlabel, not eventLabel. In a Lightweight template, params is a real JavaScript object: keys keep their case, and values keep their types.
The classic directive also fires on submit as well as click, on the element itself. PianoExternalEvent listens for click only, on its wrapper. Clicking a submit button (including implicit submission by pressing Enter in a field) still fires it; a form submitted from JavaScript does not.
Mapping table
Every classic form with its Lightweight equivalent, written out as code. Both Lightweight columns do the same thing: the PianoExternalEvent wrapper in one, the externalEventName / externalEventParams props on a Piano button in the other; pick whichever fits the markup you already have. Rows about a single attribute show only the lines that differ, and where a row needs template-side state, the JS tab snippet follows the JSX one.
|
Classic (piano-vx) |
Lightweight PianoExternalEvent equivalent |
Lightweight Button ExternalEvent equivalent |
|---|---|---|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
JavaScript
|
React JSX
JavaScript
|
|
HTML
|
React JSX
JavaScript
|
React JSX
JavaScript
|
|
HTML
|
React JSX
JavaScript
|
React JSX
JavaScript
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
Not applicable; a button does not navigate. Use the wrapper with |
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
JavaScript
|
React JSX
JavaScript
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
|
HTML
|
React JSX
|
React JSX
|
Gotchas
-
A parameter expression that throws swallows the event.
comment.valuewherecommentwas never bound (declared inside an IIFE, or the input is inside a<Show>that has not rendered) throws at click time; the click still does whatever else it does, but no event and no conversion are recorded. Use optional chainingcomment?.valueinsideparams. -
paramsis not reactive state. It is read once per fire. Do not expect acreateEffectto re-send anything. -
Wrapper vs. button. If both
PianoExternalEventand a button'sexternalEventNameare present around/on the same element, two events fire. Pick one.